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
This commit is contained in:
Theodore Li
2026-04-14 19:06:51 -04:00
committed by GitHub
parent 1bf2d95813
commit 751eeaccd4
5 changed files with 156 additions and 52 deletions
@@ -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 })
@@ -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<HTMLElement>(`[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
@@ -1317,7 +1317,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
@@ -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<string | null>(null)
const [editingName, setEditingName] = useState('')
const [isListRenaming, setIsListRenaming] = useState(false)
const [workspaceSearch, setWorkspaceSearch] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(0)
const searchInputRef = useRef<HTMLInputElement>(null)
const workspaceListRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const row = workspaceListRef.current?.querySelector<HTMLElement>(
`[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({
</div>
</div>
<DropdownMenuGroup className='mt-1 min-h-0 flex-1'>
<div className='flex max-h-[130px] flex-col gap-0.5 overflow-y-auto'>
{workspaces.map((workspace) => (
<div key={workspace.id}>
{workspaces.length > WORKSPACE_SEARCH_THRESHOLD && (
<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)]'>
<Search
className='h-[12px] w-[12px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
ref={searchInputRef}
placeholder='Search workspaces...'
value={workspaceSearch}
onChange={(e) => {
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'
/>
</div>
)}
<DropdownMenuGroup className='mt-2 min-h-0 flex-1'>
<div
ref={workspaceListRef}
className='flex max-h-[130px] flex-col gap-0.5 overflow-y-auto'
>
{filteredWorkspaces.length === 0 && workspaceSearch && (
<div className='px-2 py-[5px] text-[var(--text-tertiary)] text-caption'>
No workspaces match "{workspaceSearch}"
</div>
)}
{filteredWorkspaces.map((workspace, idx) => (
<div
key={workspace.id}
data-workspace-row-idx={idx}
onMouseEnter={() => setHighlightedIndex(idx)}
>
{editingWorkspaceId === workspace.id ? (
<div className='flex items-center gap-2 rounded-[5px] bg-[var(--surface-active)] px-2 py-[5px]'>
<input
@@ -532,9 +608,26 @@ export function WorkspaceHeader({
'hover-hover:bg-[var(--surface-hover)]',
(workspace.id === workspaceId ||
menuOpenWorkspaceId === workspace.id) &&
'bg-[var(--surface-active)]'
'bg-[var(--surface-active)]',
idx === highlightedIndex &&
workspaces.length > 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)}
>
<span className='min-w-0 flex-1 truncate'>{workspace.name}</span>
+15 -13
View File
@@ -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<TaskChatHistory>(taskKeys.detail(chatId))
if (previous) {
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), {
...previous,
resources: previous.resources.filter(
(r) => !(r.type === resourceType && r.id === resourceId)
),
})
}
return { previous }
const removed: TaskChatHistory['resources'] = []
queryClient.setQueryData<TaskChatHistory>(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<TaskChatHistory>(taskKeys.detail(chatId), (prev) =>
prev ? { ...prev, resources: [...prev.resources, ...context.removed] } : prev
)
},
onSettled: () => {
if (chatId) {