From 18513e3d534da6b2b14573617b606081d84bef07 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 14 Apr 2026 14:33:15 -0700 Subject: [PATCH 1/5] fix(ui): fix resource switching logic, multi select delete --- .../app/api/copilot/chat/resources/route.ts | 26 ++++++++-------- .../resource-tabs/resource-tabs.tsx | 30 ++++++++----------- .../[workspaceId]/home/hooks/use-chat.ts | 6 +++- apps/sim/hooks/queries/tasks.ts | 28 +++++++++-------- 4 files changed, 45 insertions(+), 45 deletions(-) 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..f44fb58cb1b 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,15 @@ 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)}"]` + ) + tab?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + }, [activeId]) + const addResource = useAddChatResource(chatId) const removeResource = useRemoveChatResource(chatId) const reorderResources = useReorderChatResources(chatId) @@ -286,24 +295,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/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) { From bd46aa99c1d1e7c00d4a82f936b5f414960e0b5b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 14 Apr 2026 14:36:13 -0700 Subject: [PATCH 2/5] Allow cmd+click on workspace menu --- .../workspace-header/workspace-header.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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..4413fe797ad 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 @@ -534,7 +534,19 @@ export function WorkspaceHeader({ menuOpenWorkspaceId === workspace.id) && 'bg-[var(--surface-active)]' )} - 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} From 60205f053ce946f286a37ab4ea2adbdbde25bfc6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 14 Apr 2026 15:05:06 -0700 Subject: [PATCH 3/5] Add search bar to workspace modal --- .../resource-tabs/resource-tabs.tsx | 4 +- .../workspace-header/workspace-header.tsx | 89 +++++++++++++++++-- 2 files changed, 84 insertions(+), 9 deletions(-) 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 f44fb58cb1b..cf0b222462d 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 @@ -183,9 +183,7 @@ export function ResourceTabs({ useEffect(() => { const node = scrollNodeRef.current if (!node || !activeId) return - const tab = node.querySelector( - `[data-resource-tab-id="${CSS.escape(activeId)}"]` - ) + const tab = node.querySelector(`[data-resource-tab-id="${CSS.escape(activeId)}"]`) tab?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) }, [activeId]) 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 4413fe797ad..03908ca2b97 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, @@ -120,6 +121,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 +190,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 +492,57 @@ export function WorkspaceHeader({ - -
- {workspaces.map((workspace) => ( -
+ {workspaces.length > 3 && ( +
+ + { + 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 ? (
{ if (e.metaKey || e.ctrlKey) { From b95c25501449d73bee7ffcb315db90bd65ee797b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 14 Apr 2026 15:26:57 -0700 Subject: [PATCH 4/5] address greptile comments --- .../components/resource-tabs/resource-tabs.tsx | 11 ++++++++++- .../components/workspace-header/workspace-header.tsx | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) 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 cf0b222462d..c771651016e 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 @@ -184,7 +184,16 @@ export function ResourceTabs({ const node = scrollNodeRef.current if (!node || !activeId) return const tab = node.querySelector(`[data-resource-tab-id="${CSS.escape(activeId)}"]`) - tab?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + if (!tab) return + const tabLeft = tab.offsetLeft + const tabRight = tabLeft + tab.offsetWidth + 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) 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 03908ca2b97..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 @@ -35,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 @@ -492,7 +495,7 @@ export function WorkspaceHeader({
- {workspaces.length > 3 && ( + {workspaces.length > WORKSPACE_SEARCH_THRESHOLD && (
WORKSPACE_SEARCH_THRESHOLD && workspace.id !== workspaceId && menuOpenWorkspaceId !== workspace.id && 'bg-[var(--surface-hover)]' From 7e8833a184f81e40d251fa2f49065f235de58aca Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 14 Apr 2026 15:48:10 -0700 Subject: [PATCH 5/5] fix resource tab scroll --- .../components/resource-tabs/resource-tabs.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 c771651016e..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 @@ -185,8 +185,12 @@ export function ResourceTabs({ if (!node || !activeId) return const tab = node.querySelector(`[data-resource-tab-id="${CSS.escape(activeId)}"]`) if (!tab) return - const tabLeft = tab.offsetLeft - const tabRight = tabLeft + tab.offsetWidth + // 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) {