Skip to content

Commit 751eeac

Browse files
fix(ui): resource tab fixes, add search to workspace modal (#4166)
* fix(ui): fix resource switching logic, multi select delete * Allow cmd+click on workspace menu * Add search bar to workspace modal * address greptile comments * fix resource tab scroll
1 parent 1bf2d95 commit 751eeac

File tree

5 files changed

+156
-52
lines changed

5 files changed

+156
-52
lines changed

apps/sim/app/api/copilot/chat/resources/route.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -169,24 +169,24 @@ export async function DELETE(req: NextRequest) {
169169
const body = await req.json()
170170
const { chatId, resourceType, resourceId } = RemoveResourceSchema.parse(body)
171171

172-
const [chat] = await db
173-
.select({ resources: copilotChats.resources })
174-
.from(copilotChats)
172+
const [updated] = await db
173+
.update(copilotChats)
174+
.set({
175+
resources: sql`COALESCE((
176+
SELECT jsonb_agg(elem)
177+
FROM jsonb_array_elements(${copilotChats.resources}) elem
178+
WHERE NOT (elem->>'type' = ${resourceType} AND elem->>'id' = ${resourceId})
179+
), '[]'::jsonb)`,
180+
updatedAt: new Date(),
181+
})
175182
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
176-
.limit(1)
183+
.returning({ resources: copilotChats.resources })
177184

178-
if (!chat) {
185+
if (!updated) {
179186
return createNotFoundResponse('Chat not found or unauthorized')
180187
}
181188

182-
const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
183-
const key = `${resourceType}:${resourceId}`
184-
const merged = existing.filter((r) => `${r.type}:${r.id}` !== key)
185-
186-
await db
187-
.update(copilotChats)
188-
.set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() })
189-
.where(eq(copilotChats.id, chatId))
189+
const merged = Array.isArray(updated.resources) ? (updated.resources as ChatResource[]) : []
190190

191191
logger.info('Removed resource from chat', { chatId, resourceType, resourceId })
192192

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,26 @@ export function ResourceTabs({
180180
return () => node.removeEventListener('wheel', handler)
181181
}, [])
182182

183+
useEffect(() => {
184+
const node = scrollNodeRef.current
185+
if (!node || !activeId) return
186+
const tab = node.querySelector<HTMLElement>(`[data-resource-tab-id="${CSS.escape(activeId)}"]`)
187+
if (!tab) return
188+
// Use bounding rects because the tab's offsetParent is a `position: relative`
189+
// wrapper, so `offsetLeft` is relative to that wrapper rather than `node`.
190+
const tabRect = tab.getBoundingClientRect()
191+
const nodeRect = node.getBoundingClientRect()
192+
const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft
193+
const tabRight = tabLeft + tabRect.width
194+
const viewLeft = node.scrollLeft
195+
const viewRight = viewLeft + node.clientWidth
196+
if (tabLeft < viewLeft) {
197+
node.scrollTo({ left: tabLeft, behavior: 'smooth' })
198+
} else if (tabRight > viewRight) {
199+
node.scrollTo({ left: tabRight - node.clientWidth, behavior: 'smooth' })
200+
}
201+
}, [activeId])
202+
183203
const addResource = useAddChatResource(chatId)
184204
const removeResource = useRemoveChatResource(chatId)
185205
const reorderResources = useReorderChatResources(chatId)
@@ -286,24 +306,9 @@ export function ResourceTabs({
286306
if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) {
287307
anchorIdRef.current = null
288308
}
289-
// Serialize mutations so each onMutate sees the cache updated by the prior
290-
// one. Continue on individual failures so remaining removals still fire.
291-
const persistable = targets.filter((r) => !isEphemeralResource(r))
292-
if (persistable.length > 0) {
293-
void (async () => {
294-
for (const r of persistable) {
295-
try {
296-
await removeResource.mutateAsync({
297-
chatId,
298-
resourceType: r.type,
299-
resourceId: r.id,
300-
})
301-
} catch {
302-
// Individual failure — the mutation's onError already rolled back
303-
// this resource in cache. Remaining removals continue.
304-
}
305-
}
306-
})()
309+
for (const r of targets) {
310+
if (isEphemeralResource(r)) continue
311+
removeResource.mutate({ chatId, resourceType: r.type, resourceId: r.id })
307312
}
308313
},
309314
// eslint-disable-next-line react-hooks/exhaustive-deps

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1317,7 +1317,11 @@ export function useChat(
13171317
const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file')
13181318
if (persistedResources.length > 0) {
13191319
setResources(persistedResources)
1320-
setActiveResourceId(persistedResources[persistedResources.length - 1].id)
1320+
setActiveResourceId((prev) =>
1321+
prev && persistedResources.some((r) => r.id === prev)
1322+
? prev
1323+
: persistedResources[persistedResources.length - 1].id
1324+
)
13211325

13221326
for (const resource of persistedResources) {
13231327
if (resource.type !== 'workflow') continue

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useEffect, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { MoreHorizontal } from 'lucide-react'
5+
import { MoreHorizontal, Search } from 'lucide-react'
66
import {
77
Button,
88
ChevronDown,
@@ -11,6 +11,7 @@ import {
1111
DropdownMenuGroup,
1212
DropdownMenuSeparator,
1313
DropdownMenuTrigger,
14+
Input,
1415
Modal,
1516
ModalBody,
1617
ModalContent,
@@ -34,6 +35,9 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
3435

3536
const logger = createLogger('WorkspaceHeader')
3637

38+
/** Minimum workspace count before the search input and keyboard navigation are shown. */
39+
const WORKSPACE_SEARCH_THRESHOLD = 3
40+
3741
interface WorkspaceHeaderProps {
3842
/** The active workspace object */
3943
activeWorkspace?: { name: string } | null
@@ -120,6 +124,22 @@ export function WorkspaceHeader({
120124
const [editingWorkspaceId, setEditingWorkspaceId] = useState<string | null>(null)
121125
const [editingName, setEditingName] = useState('')
122126
const [isListRenaming, setIsListRenaming] = useState(false)
127+
const [workspaceSearch, setWorkspaceSearch] = useState('')
128+
const [highlightedIndex, setHighlightedIndex] = useState(0)
129+
const searchInputRef = useRef<HTMLInputElement>(null)
130+
const workspaceListRef = useRef<HTMLDivElement>(null)
131+
132+
useEffect(() => {
133+
const row = workspaceListRef.current?.querySelector<HTMLElement>(
134+
`[data-workspace-row-idx="${highlightedIndex}"]`
135+
)
136+
row?.scrollIntoView({ block: 'nearest' })
137+
}, [highlightedIndex])
138+
139+
const searchQuery = workspaceSearch.trim().toLowerCase()
140+
const filteredWorkspaces = searchQuery
141+
? workspaces.filter((w) => w.name.toLowerCase().includes(searchQuery))
142+
: workspaces
123143

124144
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
125145
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
@@ -173,6 +193,15 @@ export function WorkspaceHeader({
173193
}
174194
}, [isWorkspaceMenuOpen, editingWorkspaceId, editingName, workspaces, onRenameWorkspace])
175195

196+
useEffect(() => {
197+
if (isWorkspaceMenuOpen) {
198+
setHighlightedIndex(0)
199+
const id = requestAnimationFrame(() => searchInputRef.current?.focus())
200+
return () => cancelAnimationFrame(id)
201+
}
202+
setWorkspaceSearch('')
203+
}, [isWorkspaceMenuOpen])
204+
176205
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
177206

178207
const workspaceInitial = (() => {
@@ -466,10 +495,57 @@ export function WorkspaceHeader({
466495
</div>
467496
</div>
468497

469-
<DropdownMenuGroup className='mt-1 min-h-0 flex-1'>
470-
<div className='flex max-h-[130px] flex-col gap-0.5 overflow-y-auto'>
471-
{workspaces.map((workspace) => (
472-
<div key={workspace.id}>
498+
{workspaces.length > WORKSPACE_SEARCH_THRESHOLD && (
499+
<div className='mt-1 flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-transparent px-2 py-1 transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
500+
<Search
501+
className='h-[12px] w-[12px] flex-shrink-0 text-[var(--text-tertiary)]'
502+
strokeWidth={2}
503+
/>
504+
<Input
505+
ref={searchInputRef}
506+
placeholder='Search workspaces...'
507+
value={workspaceSearch}
508+
onChange={(e) => {
509+
setWorkspaceSearch(e.target.value)
510+
setHighlightedIndex(0)
511+
}}
512+
onKeyDown={(e) => {
513+
e.stopPropagation()
514+
if (filteredWorkspaces.length === 0) return
515+
if (e.key === 'ArrowDown') {
516+
e.preventDefault()
517+
setHighlightedIndex((i) => (i + 1) % filteredWorkspaces.length)
518+
} else if (e.key === 'ArrowUp') {
519+
e.preventDefault()
520+
setHighlightedIndex(
521+
(i) => (i - 1 + filteredWorkspaces.length) % filteredWorkspaces.length
522+
)
523+
} else if (e.key === 'Enter') {
524+
e.preventDefault()
525+
const target = filteredWorkspaces[highlightedIndex]
526+
if (target) onWorkspaceSwitch(target)
527+
}
528+
}}
529+
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'
530+
/>
531+
</div>
532+
)}
533+
<DropdownMenuGroup className='mt-2 min-h-0 flex-1'>
534+
<div
535+
ref={workspaceListRef}
536+
className='flex max-h-[130px] flex-col gap-0.5 overflow-y-auto'
537+
>
538+
{filteredWorkspaces.length === 0 && workspaceSearch && (
539+
<div className='px-2 py-[5px] text-[var(--text-tertiary)] text-caption'>
540+
No workspaces match "{workspaceSearch}"
541+
</div>
542+
)}
543+
{filteredWorkspaces.map((workspace, idx) => (
544+
<div
545+
key={workspace.id}
546+
data-workspace-row-idx={idx}
547+
onMouseEnter={() => setHighlightedIndex(idx)}
548+
>
473549
{editingWorkspaceId === workspace.id ? (
474550
<div className='flex items-center gap-2 rounded-[5px] bg-[var(--surface-active)] px-2 py-[5px]'>
475551
<input
@@ -532,9 +608,26 @@ export function WorkspaceHeader({
532608
'hover-hover:bg-[var(--surface-hover)]',
533609
(workspace.id === workspaceId ||
534610
menuOpenWorkspaceId === workspace.id) &&
535-
'bg-[var(--surface-active)]'
611+
'bg-[var(--surface-active)]',
612+
idx === highlightedIndex &&
613+
workspaces.length > WORKSPACE_SEARCH_THRESHOLD &&
614+
workspace.id !== workspaceId &&
615+
menuOpenWorkspaceId !== workspace.id &&
616+
'bg-[var(--surface-hover)]'
536617
)}
537-
onClick={() => onWorkspaceSwitch(workspace)}
618+
onClick={(e) => {
619+
if (e.metaKey || e.ctrlKey) {
620+
window.open(`/workspace/${workspace.id}/home`, '_blank')
621+
return
622+
}
623+
onWorkspaceSwitch(workspace)
624+
}}
625+
onAuxClick={(e) => {
626+
if (e.button === 1) {
627+
e.preventDefault()
628+
window.open(`/workspace/${workspace.id}/home`, '_blank')
629+
}
630+
}}
538631
onContextMenu={(e) => handleContextMenu(e, workspace)}
539632
>
540633
<span className='min-w-0 flex-1 truncate'>{workspace.name}</span>

apps/sim/hooks/queries/tasks.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -485,21 +485,23 @@ export function useRemoveChatResource(chatId?: string) {
485485
onMutate: async ({ resourceType, resourceId }) => {
486486
if (!chatId) return
487487
await queryClient.cancelQueries({ queryKey: taskKeys.detail(chatId) })
488-
const previous = queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(chatId))
489-
if (previous) {
490-
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), {
491-
...previous,
492-
resources: previous.resources.filter(
493-
(r) => !(r.type === resourceType && r.id === resourceId)
494-
),
495-
})
496-
}
497-
return { previous }
488+
const removed: TaskChatHistory['resources'] = []
489+
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), (prev) => {
490+
if (!prev) return prev
491+
const next: TaskChatHistory['resources'] = []
492+
for (const r of prev.resources) {
493+
if (r.type === resourceType && r.id === resourceId) removed.push(r)
494+
else next.push(r)
495+
}
496+
return removed.length > 0 ? { ...prev, resources: next } : prev
497+
})
498+
return { removed }
498499
},
499500
onError: (_err, _variables, context) => {
500-
if (context?.previous && chatId) {
501-
queryClient.setQueryData(taskKeys.detail(chatId), context.previous)
502-
}
501+
if (!chatId || !context?.removed.length) return
502+
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), (prev) =>
503+
prev ? { ...prev, resources: [...prev.resources, ...context.removed] } : prev
504+
)
503505
},
504506
onSettled: () => {
505507
if (chatId) {

0 commit comments

Comments
 (0)