mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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 })
|
||||
|
||||
|
||||
+23
-18
@@ -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
|
||||
|
||||
+100
-7
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user