Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions apps/sim/app/api/copilot/chat/resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

const addResource = useAddChatResource(chatId)
const removeResource = useRemoveChatResource(chatId)
const reorderResources = useReorderChatResources(chatId)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -11,6 +11,7 @@ import {
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
Modal,
ModalBody,
ModalContent,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = (() => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)]'
Comment thread
TheodoreSpeaks marked this conversation as resolved.
)}
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>
Expand Down
28 changes: 15 additions & 13 deletions apps/sim/hooks/queries/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading