diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index f6042138c5e..9335c86d07d 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -169,24 +169,24 @@ export async function DELETE(req: NextRequest) { const body = await req.json() const { chatId, resourceType, resourceId } = RemoveResourceSchema.parse(body) - const [chat] = await db - .select({ resources: copilotChats.resources }) - .from(copilotChats) + const [updated] = await db + .update(copilotChats) + .set({ + resources: sql`COALESCE(( + SELECT jsonb_agg(elem) + FROM jsonb_array_elements(${copilotChats.resources}) elem + WHERE NOT (elem->>'type' = ${resourceType} AND elem->>'id' = ${resourceId}) + ), '[]'::jsonb)`, + updatedAt: new Date(), + }) .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) - .limit(1) + .returning({ resources: copilotChats.resources }) - if (!chat) { + if (!updated) { return createNotFoundResponse('Chat not found or unauthorized') } - const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] - const key = `${resourceType}:${resourceId}` - const merged = existing.filter((r) => `${r.type}:${r.id}` !== key) - - await db - .update(copilotChats) - .set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() }) - .where(eq(copilotChats.id, chatId)) + const merged = Array.isArray(updated.resources) ? (updated.resources as ChatResource[]) : [] logger.info('Removed resource from chat', { chatId, resourceType, resourceId }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index 68fb65c1ac9..d109db1fa61 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -180,6 +180,26 @@ export function ResourceTabs({ return () => node.removeEventListener('wheel', handler) }, []) + useEffect(() => { + const node = scrollNodeRef.current + if (!node || !activeId) return + const tab = node.querySelector(`[data-resource-tab-id="${CSS.escape(activeId)}"]`) + if (!tab) return + // Use bounding rects because the tab's offsetParent is a `position: relative` + // wrapper, so `offsetLeft` is relative to that wrapper rather than `node`. + const tabRect = tab.getBoundingClientRect() + const nodeRect = node.getBoundingClientRect() + const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft + const tabRight = tabLeft + tabRect.width + const viewLeft = node.scrollLeft + const viewRight = viewLeft + node.clientWidth + if (tabLeft < viewLeft) { + node.scrollTo({ left: tabLeft, behavior: 'smooth' }) + } else if (tabRight > viewRight) { + node.scrollTo({ left: tabRight - node.clientWidth, behavior: 'smooth' }) + } + }, [activeId]) + const addResource = useAddChatResource(chatId) const removeResource = useRemoveChatResource(chatId) const reorderResources = useReorderChatResources(chatId) @@ -286,24 +306,9 @@ export function ResourceTabs({ if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) { anchorIdRef.current = null } - // Serialize mutations so each onMutate sees the cache updated by the prior - // one. Continue on individual failures so remaining removals still fire. - const persistable = targets.filter((r) => !isEphemeralResource(r)) - if (persistable.length > 0) { - void (async () => { - for (const r of persistable) { - try { - await removeResource.mutateAsync({ - chatId, - resourceType: r.type, - resourceId: r.id, - }) - } catch { - // Individual failure — the mutation's onError already rolled back - // this resource in cache. Remaining removals continue. - } - } - })() + for (const r of targets) { + if (isEphemeralResource(r)) continue + removeResource.mutate({ chatId, resourceType: r.type, resourceId: r.id }) } }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 6a2ca9e2c31..b45877d9083 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1277,7 +1277,11 @@ export function useChat( const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file') if (persistedResources.length > 0) { setResources(persistedResources) - setActiveResourceId(persistedResources[persistedResources.length - 1].id) + setActiveResourceId((prev) => + prev && persistedResources.some((r) => r.id === prev) + ? prev + : persistedResources[persistedResources.length - 1].id + ) for (const resource of persistedResources) { if (resource.type !== 'workflow') continue diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index ea722bb2ce9..4d48a62bff0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { MoreHorizontal } from 'lucide-react' +import { MoreHorizontal, Search } from 'lucide-react' import { Button, ChevronDown, @@ -11,6 +11,7 @@ import { DropdownMenuGroup, DropdownMenuSeparator, DropdownMenuTrigger, + Input, Modal, ModalBody, ModalContent, @@ -34,6 +35,9 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation' const logger = createLogger('WorkspaceHeader') +/** Minimum workspace count before the search input and keyboard navigation are shown. */ +const WORKSPACE_SEARCH_THRESHOLD = 3 + interface WorkspaceHeaderProps { /** The active workspace object */ activeWorkspace?: { name: string } | null @@ -120,6 +124,22 @@ export function WorkspaceHeader({ const [editingWorkspaceId, setEditingWorkspaceId] = useState(null) const [editingName, setEditingName] = useState('') const [isListRenaming, setIsListRenaming] = useState(false) + const [workspaceSearch, setWorkspaceSearch] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(0) + const searchInputRef = useRef(null) + const workspaceListRef = useRef(null) + + useEffect(() => { + const row = workspaceListRef.current?.querySelector( + `[data-workspace-row-idx="${highlightedIndex}"]` + ) + row?.scrollIntoView({ block: 'nearest' }) + }, [highlightedIndex]) + + const searchQuery = workspaceSearch.trim().toLowerCase() + const filteredWorkspaces = searchQuery + ? workspaces.filter((w) => w.name.toLowerCase().includes(searchQuery)) + : workspaces const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) @@ -173,6 +193,15 @@ export function WorkspaceHeader({ } }, [isWorkspaceMenuOpen, editingWorkspaceId, editingName, workspaces, onRenameWorkspace]) + useEffect(() => { + if (isWorkspaceMenuOpen) { + setHighlightedIndex(0) + const id = requestAnimationFrame(() => searchInputRef.current?.focus()) + return () => cancelAnimationFrame(id) + } + setWorkspaceSearch('') + }, [isWorkspaceMenuOpen]) + const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null const workspaceInitial = (() => { @@ -466,10 +495,57 @@ export function WorkspaceHeader({ - -
- {workspaces.map((workspace) => ( -
+ {workspaces.length > WORKSPACE_SEARCH_THRESHOLD && ( +
+ + { + setWorkspaceSearch(e.target.value) + setHighlightedIndex(0) + }} + onKeyDown={(e) => { + e.stopPropagation() + if (filteredWorkspaces.length === 0) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setHighlightedIndex((i) => (i + 1) % filteredWorkspaces.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setHighlightedIndex( + (i) => (i - 1 + filteredWorkspaces.length) % filteredWorkspaces.length + ) + } else if (e.key === 'Enter') { + e.preventDefault() + const target = filteredWorkspaces[highlightedIndex] + if (target) onWorkspaceSwitch(target) + } + }} + className='h-auto flex-1 border-0 bg-transparent p-0 text-caption leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ )} + +
+ {filteredWorkspaces.length === 0 && workspaceSearch && ( +
+ No workspaces match "{workspaceSearch}" +
+ )} + {filteredWorkspaces.map((workspace, idx) => ( +
setHighlightedIndex(idx)} + > {editingWorkspaceId === workspace.id ? (
WORKSPACE_SEARCH_THRESHOLD && + workspace.id !== workspaceId && + menuOpenWorkspaceId !== workspace.id && + 'bg-[var(--surface-hover)]' )} - onClick={() => onWorkspaceSwitch(workspace)} + onClick={(e) => { + if (e.metaKey || e.ctrlKey) { + window.open(`/workspace/${workspace.id}/home`, '_blank') + return + } + onWorkspaceSwitch(workspace) + }} + onAuxClick={(e) => { + if (e.button === 1) { + e.preventDefault() + window.open(`/workspace/${workspace.id}/home`, '_blank') + } + }} onContextMenu={(e) => handleContextMenu(e, workspace)} > {workspace.name} diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index e45429d613c..ebfd65bd38f 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -485,21 +485,23 @@ export function useRemoveChatResource(chatId?: string) { onMutate: async ({ resourceType, resourceId }) => { if (!chatId) return await queryClient.cancelQueries({ queryKey: taskKeys.detail(chatId) }) - const previous = queryClient.getQueryData(taskKeys.detail(chatId)) - if (previous) { - queryClient.setQueryData(taskKeys.detail(chatId), { - ...previous, - resources: previous.resources.filter( - (r) => !(r.type === resourceType && r.id === resourceId) - ), - }) - } - return { previous } + const removed: TaskChatHistory['resources'] = [] + queryClient.setQueryData(taskKeys.detail(chatId), (prev) => { + if (!prev) return prev + const next: TaskChatHistory['resources'] = [] + for (const r of prev.resources) { + if (r.type === resourceType && r.id === resourceId) removed.push(r) + else next.push(r) + } + return removed.length > 0 ? { ...prev, resources: next } : prev + }) + return { removed } }, onError: (_err, _variables, context) => { - if (context?.previous && chatId) { - queryClient.setQueryData(taskKeys.detail(chatId), context.previous) - } + if (!chatId || !context?.removed.length) return + queryClient.setQueryData(taskKeys.detail(chatId), (prev) => + prev ? { ...prev, resources: [...prev.resources, ...context.removed] } : prev + ) }, onSettled: () => { if (chatId) {