From cd339c805df552cbaa18e5223483bcf4a3fea680 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:43:51 -0700 Subject: [PATCH 01/17] feat(copilot): add create_table_view and edit_table_view Direct main-agent tools for saved table views. create_table_view takes a table id (optional name, config, isDefault) and returns the view id; edit_table_view takes a view id plus a config patch and resolves the owning table from the view. Both results name the table and view, so the resource panel opens the table pinned to that view, and an already-open table switches to it once its views list carries the id (view-pin store). viewId now rides the resource stream descriptor and chat-resource persistence so the pin survives reopening the chat. --- .../app/api/copilot/chat/resources/route.ts | 17 +- .../home/components/message-content/utils.ts | 2 + .../resource-registry/resource-registry.tsx | 3 + .../stream/handle-resource-event.test.ts | 79 +++++++++ .../hooks/stream/handle-resource-event.ts | 25 +++ .../home/hooks/stream/stream-helpers.ts | 1 + .../[workspaceId]/tables/[tableId]/table.tsx | 23 +++ apps/sim/hooks/queries/mothership-chats.ts | 1 + apps/sim/lib/api/contracts/copilot.ts | 3 + .../sim/lib/api/contracts/mothership-chats.ts | 2 + .../generated/mothership-stream-v1-schema.ts | 3 + .../copilot/generated/mothership-stream-v1.ts | 1 + .../lib/copilot/generated/tool-catalog-v1.ts | 154 +++++++++++++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 161 ++++++++++++++++++ .../copilot/request/session/contract.test.ts | 29 ++++ .../lib/copilot/request/session/contract.ts | 6 +- .../lib/copilot/request/tools/resources.ts | 9 +- .../lib/copilot/resources/extraction.test.ts | 36 ++++ apps/sim/lib/copilot/resources/extraction.ts | 21 +++ apps/sim/lib/copilot/resources/persistence.ts | 10 +- apps/sim/lib/copilot/resources/types.test.ts | 30 ++++ apps/sim/lib/copilot/resources/types.ts | 21 +++ apps/sim/lib/copilot/tools/server/router.ts | 9 + .../server/table/create-table-view.test.ts | 141 +++++++++++++++ .../tools/server/table/create-table-view.ts | 62 +++++++ .../server/table/edit-table-view.test.ts | 144 ++++++++++++++++ .../tools/server/table/edit-table-view.ts | 73 ++++++++ .../copilot/tools/server/table/table-views.ts | 49 ++---- .../tools/server/table/view-tool-shared.ts | 72 ++++++++ .../lib/copilot/tools/tool-display.test.ts | 15 ++ apps/sim/lib/copilot/tools/tool-display.ts | 19 +++ apps/sim/lib/copilot/vfs/serializers.ts | 2 +- apps/sim/lib/table/application/context.ts | 31 ++++ apps/sim/lib/table/application/views.ts | 38 ++++- apps/sim/lib/table/views/service.test.ts | 53 ++++++ apps/sim/lib/table/views/service.ts | 41 ++++- apps/sim/stores/table/view-pin/store.test.ts | 52 ++++++ apps/sim/stores/table/view-pin/store.ts | 55 ++++++ 38 files changed, 1434 insertions(+), 59 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/create-table-view.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/edit-table-view.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/edit-table-view.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/view-tool-shared.ts create mode 100644 apps/sim/stores/table/view-pin/store.test.ts create mode 100644 apps/sim/stores/table/view-pin/store.ts diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 85b81323a65..2d4402fd8d0 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -19,7 +19,7 @@ import { import type { ChatResource } from '@/lib/copilot/resources/persistence' import { canonicalizeDesktopSessionResource, - GENERIC_RESOURCE_TITLES, + mergeChatResource, sanitizeChatResources, } from '@/lib/copilot/resources/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -73,18 +73,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const key = `${resource.type}:${resource.id}` const prev = existing.find((r) => `${r.type}:${r.id}` === key) - let merged: ChatResource[] - if (prev) { - if (GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(resource.title)) { - merged = existing.map((r) => - `${r.type}:${r.id}` === key ? { ...r, title: resource.title } : r - ) - } else { - merged = existing - } - } else { - merged = [...existing, resource] - } + const merged: ChatResource[] = prev + ? existing.map((r) => (`${r.type}:${r.id}` === key ? mergeChatResource(r, resource) : r)) + : [...existing, resource] await db .update(copilotChats) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 64176c822c5..e4e84f176b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -58,6 +58,8 @@ const TOOL_ICONS: Record = { search_knowledge_base: Database, table: TableIcon, query_user_table: TableIcon, + create_table_view: TableIcon, + edit_table_view: TableIcon, job: Calendar, agent: AgentIcon, custom_tool: Wrench, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 029c33f90f7..e73e00af835 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -300,6 +300,9 @@ const RESOURCE_INVALIDATORS: Record< table: (qc, _wId, id) => { qc.invalidateQueries({ queryKey: tableKeys.lists() }) qc.invalidateQueries({ queryKey: tableKeys.detail(id) }) + // A view the agent just created must be in the list before the embedded + // table can switch to it; see the view-pin store. + qc.invalidateQueries({ queryKey: tableKeys.views(id) }) }, file: (qc, wId, id) => { qc.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts index 5eb8df1154d..e858d049e28 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts @@ -20,6 +20,8 @@ import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { useTableViewPinStore } from '@/stores/table/view-pin/store' function removeEvent(type: 'workflow' | 'file', id: string): PersistedStreamEventEnvelope { return { @@ -105,3 +107,80 @@ describe('handleResourceEvent removal', () => { expect(onResourceEvent).toHaveBeenCalledWith('browser-session') }) }) + +function tableUpsertEvent(id: string, viewId?: string): PersistedStreamEventEnvelope { + return { + type: 'resource', + v: 1, + seq: 1, + ts: '', + stream: { streamId: 's', cursor: '1' }, + payload: { + op: 'upsert', + resource: { type: 'table', id, title: 'Invoices', ...(viewId ? { viewId } : {}) }, + }, + } as PersistedStreamEventEnvelope +} + +describe('handleResourceEvent saved-view pins', () => { + beforeEach(() => { + vi.clearAllMocks() + useTableViewPinStore.getState().reset() + }) + + it('opens a closed table on the view and leaves a pin for the table to consume', () => { + const onResourceEvent = vi.fn() + const deps = makeStreamLoopDeps({ onResourceEventRef: { current: onResourceEvent } }) + const ctx = { deps } as StreamLoopContext + + handleResourceEvent(ctx, tableUpsertEvent('tbl-1', 'view-1')) + + expect(deps.addResource).toHaveBeenCalledWith({ + type: 'table', + id: 'tbl-1', + title: 'Invoices', + viewId: 'view-1', + }) + expect(deps.setResources).not.toHaveBeenCalled() + expect(useTableViewPinStore.getState().pins['tbl-1']?.viewId).toBe('view-1') + expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith( + deps.queryClient, + 'ws-1', + 'table', + 'tbl-1' + ) + expect(onResourceEvent).toHaveBeenCalledWith('tbl-1') + }) + + it('moves the pin on an already-open table so a remount and the live grid both follow', () => { + const open: MothershipResource = { + type: 'table', + id: 'tbl-1', + title: 'Invoices', + viewId: 'view-1', + } + const deps = makeStreamLoopDeps({ + addResource: vi.fn(() => false), + resourcesRef: { current: [open] }, + }) + const ctx = { deps } as StreamLoopContext + + handleResourceEvent(ctx, tableUpsertEvent('tbl-1', 'view-2')) + + const updater = (deps.setResources as ReturnType).mock.calls[0][0] as ( + current: MothershipResource[] + ) => MothershipResource[] + expect(updater([open])).toEqual([{ ...open, viewId: 'view-2' }]) + expect(useTableViewPinStore.getState().pins['tbl-1']?.viewId).toBe('view-2') + }) + + it('ignores a pin on anything but a table and leaves unpinned tables alone', () => { + const deps = makeStreamLoopDeps({ addResource: vi.fn(() => false) }) + const ctx = { deps } as StreamLoopContext + + handleResourceEvent(ctx, tableUpsertEvent('tbl-1')) + + expect(deps.setResources).not.toHaveBeenCalled() + expect(useTableViewPinStore.getState().pins['tbl-1']).toBeUndefined() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index 12c1a2d6f94..9c309e0de8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -13,6 +13,7 @@ import { import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' +import { useTableViewPinStore } from '@/stores/table/view-pin/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' type ResourceEvent = Extract< @@ -44,11 +45,20 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven } = ctx.deps const onResourceEvent = onResourceEventRef.current const payload = parsed.payload + // A saved view the agent just created or edited: the table opens on it, and + // an already-open table switches to it. + const pinnedViewId = + payload.resource.type === 'table' && + typeof payload.resource.viewId === 'string' && + payload.resource.viewId.trim() + ? payload.resource.viewId + : undefined const resource = canonicalizeDesktopSessionResource({ type: payload.resource.type as MothershipResourceType, id: payload.resource.id, title: typeof payload.resource.title === 'string' ? payload.resource.title : payload.resource.id, + ...(pinnedViewId ? { viewId: pinnedViewId } : {}), }) if (payload.op === MothershipStreamV1ResourceOp.remove) { @@ -111,6 +121,21 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven completedPreviewResourceHandoffRef.current.delete(resource.id) previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId) } + if (pinnedViewId) { + if (!wasAdded) { + // The tab already exists: carry the newest pin so a remount adopts it. + setResources((current) => + current.some((r) => r.type === 'table' && r.id === resource.id && r.viewId !== pinnedViewId) + ? current.map((r) => + r.type === 'table' && r.id === resource.id ? { ...r, viewId: pinnedViewId } : r + ) + : current + ) + } + // Consumed by the embedded table once its views list carries the view — + // which may be after the refetch below lands, or after the tab first opens. + useTableViewPinStore.getState().pin(resource.id, pinnedViewId) + } invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id) if (!shouldSuppressFileResourceActivation) onResourceEvent?.(resource.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index afeb703e08d..053ca1918bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -221,6 +221,7 @@ export function resolveIntegrationToolDisplayTitle(tool: { * client resolves the id against the workflow registry. */ const TABLE_SCOPED_TOOL_IDS = new Set([ + 'create_table_view', 'table_automations', 'table_columns', 'table_enrichments', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index ee9f6b52e27..1be15bf937f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -68,6 +68,7 @@ import { useInlineRename } from '@/hooks/use-inline-rename' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useLogDetailsUIStore } from '@/stores/logs/store' import type { DeletedRowSnapshot } from '@/stores/table/types' +import { useTableViewPinStore } from '@/stores/table/view-pin/store' import { type ColumnConfig, ColumnConfigSidebar, @@ -701,6 +702,28 @@ export function Table({ tableData?.metadata, ]) + /** + * A view the agent just created or edited (see the view-pin store). Applied + * only once the views list carries it — the pin arrives ahead of the list + * refetch, and writing the URL earlier would name a view the effect above + * resolves to nothing and treats as dead. First adoption is left to that + * effect (it honours `initialViewId` itself); a pin that turns out to be the + * view already applied is consumed without a URL write. + */ + const viewPin = useTableViewPinStore((state) => state.pins[tableId]) + const consumeViewPin = useTableViewPinStore((state) => state.consume) + useEffect(() => { + if (!embedded || !viewPin) return + if (appliedViewRevisionRef.current === undefined) return + if (!views.some((view) => view.id === viewPin.viewId)) return + consumeViewPin(tableId, viewPin.seq) + if (activeViewId === viewPin.viewId || appliedViewRevisionRef.current.id === viewPin.viewId) { + return + } + preservedViewStateRef.current = null + setTableParams({ view: viewPin.viewId }) + }, [embedded, viewPin, views, activeViewId, tableId, consumeViewPin, setTableParams]) + /** * Live state pruned the same way `pruneViewConfig` prunes the stored config on * read. Without this, deleting a hidden or sorted column leaves the local ids diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 0c05de3ceb0..7dacc9ce14b 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -138,6 +138,7 @@ function parseResource(value: unknown, context: string): MothershipResource { type: value.type, id: value.id, title: value.title, + ...(typeof value.viewId === 'string' && value.viewId ? { viewId: value.viewId } : {}), } } diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 7a46337b03a..209f8990d07 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -106,6 +106,8 @@ export const addCopilotChatResourceBodySchema = z.object({ // Matches the bound the chat-send path enforces. id: requiredFieldSchema('resource.id cannot be empty'), title: z.string(), + // Saved view a table tab is pinned to (type "table" only). + viewId: z.string().min(1).optional(), }), }) export type AddCopilotChatResourceBody = z.input @@ -423,6 +425,7 @@ const copilotChatResourceSchema = z.object({ type: copilotResourceTypeSchema, id: z.string(), title: z.string(), + viewId: z.string().optional(), }) const copilotAvailableModelSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 6350046512d..134fe7c5952 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -201,6 +201,8 @@ const mothershipChatResourceItemSchema = z.object({ type: z.string(), id: z.string(), title: z.string(), + /** Saved view a table tab is pinned to (type "table" only); dropped here, it would be lost on reorder. */ + viewId: z.string().min(1).optional(), }) const mothershipChatResourcesResponseSchema = z.object({ diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index e7440fb3d0f..cd4f1c943de 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -372,6 +372,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { type: { type: 'string', }, + viewId: { + type: 'string', + }, }, required: ['type', 'id'], type: 'object', diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index 3b47c736f7e..848d92531dd 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -282,6 +282,7 @@ export interface MothershipStreamV1ResourceDescriptor { id: string title?: string type: string + viewId?: string } export interface MothershipStreamV1ResourceRemoveEventEnvelope { payload: MothershipStreamV1ResourceRemovePayload diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index bbb44542619..440c962f206 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -37,6 +37,7 @@ export interface ToolCatalogEntry { | 'connect_slack_bot' | 'cp' | 'create_empty_file' + | 'create_table_view' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_workspace_mcp_server' @@ -46,6 +47,7 @@ export interface ToolCatalogEntry { | 'deploy_as_mcp' | 'diff_workflows' | 'download_file' + | 'edit_table_view' | 'edit_workflow' | 'extensions' | 'extract_doc_assets' @@ -165,6 +167,7 @@ export interface ToolCatalogEntry { | 'connect_slack_bot' | 'cp' | 'create_empty_file' + | 'create_table_view' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_workspace_mcp_server' @@ -174,6 +177,7 @@ export interface ToolCatalogEntry { | 'deploy_as_mcp' | 'diff_workflows' | 'download_file' + | 'edit_table_view' | 'edit_workflow' | 'extensions' | 'extract_doc_assets' @@ -1739,6 +1743,82 @@ export const CreateEmptyFile: ToolCatalogEntry = { capabilities: ['file_output'], } +export const CreateTableView: ToolCatalogEntry = { + id: 'create_table_view', + name: 'create_table_view', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + config: { + type: 'object', + description: + "Saved configuration, in the same shape as an entry of the table's views.json. Omit for an unfiltered view that shows every row and column.", + properties: { + filter: { + type: ['object', 'null'], + description: + 'Row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null to show every row.', + }, + hiddenColumns: { + type: 'array', + description: + 'Column names hidden in the UI while this view is active. Display-only — queries through the view still return every column.', + items: { type: 'string' }, + }, + sort: { + type: ['array', 'null'], + description: + 'Ordered sort spec, e.g. [{"field":"due","direction":"asc"}], with column NAMES. Omit or null for the table\'s natural order.', + items: { + type: 'object', + properties: { + direction: { + type: 'string', + description: 'Sort direction for this column.', + enum: ['asc', 'desc'], + }, + field: { type: 'string', description: 'Exact column name to sort by.' }, + }, + required: ['field', 'direction'], + }, + }, + }, + }, + isDefault: { + type: 'boolean', + description: + "Make this view the table's default: the view the table opens on when nobody has picked one. At most one per table; setting it clears the previous default.", + }, + name: { + type: 'string', + description: + 'Display name for the view, e.g. "Overdue". Defaults to "View N" when omitted. References always use the view id, so the name is purely display.', + }, + tableId: { type: 'string', description: "Table ID (tbl_...) from the table's meta.json." }, + }, + required: ['tableId'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + '{ viewId, tableId, tableName, view } — view is the created view in the same shape as a views.json entry.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary, including the new view id.', + }, + success: { type: 'boolean', description: 'Whether the view was created.' }, + }, + required: ['success', 'message'], + }, + requiredPermission: 'write', +} + export const CreateWorkflow: ToolCatalogEntry = { id: 'create_workflow', name: 'create_workflow', @@ -2234,6 +2314,78 @@ export const DownloadFile: ToolCatalogEntry = { capabilities: ['file_output'], } +export const EditTableView: ToolCatalogEntry = { + id: 'edit_table_view', + name: 'edit_table_view', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + config: { + type: 'object', + description: + "Configuration parts to replace, in the same shape as an entry of the table's views.json. Each part you include replaces the saved one; omitted parts are kept.", + properties: { + filter: { + type: ['object', 'null'], + description: + 'Row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit to keep the saved filter; null to clear it.', + }, + hiddenColumns: { + type: 'array', + description: + 'Column names hidden in the UI while this view is active; replaces the saved list (pass [] to unhide everything). Display-only — queries through the view still return every column.', + items: { type: 'string' }, + }, + sort: { + type: ['array', 'null'], + description: + 'Ordered sort spec, e.g. [{"field":"due","direction":"asc"}], with column NAMES. Omit to keep the saved sort; null to clear it.', + items: { + type: 'object', + properties: { + direction: { + type: 'string', + description: 'Sort direction for this column.', + enum: ['asc', 'desc'], + }, + field: { type: 'string', description: 'Exact column name to sort by.' }, + }, + required: ['field', 'direction'], + }, + }, + }, + }, + isDefault: { + type: 'boolean', + description: + "true makes this view the table's default (clearing the previous default); false demotes it. Omit to leave the flag as it is.", + }, + name: { + type: 'string', + description: 'New display name for the view. Omit to keep the current name.', + }, + viewId: { type: 'string', description: "View ID from the table's views.json." }, + }, + required: ['viewId'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + '{ viewId, tableId, tableName, view } — view is the updated view in the same shape as a views.json entry.', + }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the view was updated.' }, + }, + required: ['success', 'message'], + }, + requiredPermission: 'write', +} + export const EditWorkflow: ToolCatalogEntry = { id: 'edit_workflow', name: 'edit_workflow', @@ -7007,6 +7159,7 @@ export const TOOL_CATALOG: Record = { [ConnectSlackBot.id]: ConnectSlackBot, [Cp.id]: Cp, [CreateEmptyFile.id]: CreateEmptyFile, + [CreateTableView.id]: CreateTableView, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, @@ -7016,6 +7169,7 @@ export const TOOL_CATALOG: Record = { [DeployAsMcp.id]: DeployAsMcp, [DiffWorkflows.id]: DiffWorkflows, [DownloadFile.id]: DownloadFile, + [EditTableView.id]: EditTableView, [EditWorkflow.id]: EditWorkflow, [Extensions.id]: Extensions, [ExtractDocAssets.id]: ExtractDocAssets, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index c7f0cfcd3bf..888f3eca7c9 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1681,6 +1681,87 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + create_table_view: { + parameters: { + type: 'object', + properties: { + config: { + type: 'object', + description: + "Saved configuration, in the same shape as an entry of the table's views.json. Omit for an unfiltered view that shows every row and column.", + properties: { + filter: { + type: ['object', 'null'], + description: + 'Row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null to show every row.', + }, + hiddenColumns: { + type: 'array', + description: + 'Column names hidden in the UI while this view is active. Display-only — queries through the view still return every column.', + items: { + type: 'string', + }, + }, + sort: { + type: ['array', 'null'], + description: + 'Ordered sort spec, e.g. [{"field":"due","direction":"asc"}], with column NAMES. Omit or null for the table\'s natural order.', + items: { + type: 'object', + properties: { + direction: { + type: 'string', + description: 'Sort direction for this column.', + enum: ['asc', 'desc'], + }, + field: { + type: 'string', + description: 'Exact column name to sort by.', + }, + }, + required: ['field', 'direction'], + }, + }, + }, + }, + isDefault: { + type: 'boolean', + description: + "Make this view the table's default: the view the table opens on when nobody has picked one. At most one per table; setting it clears the previous default.", + }, + name: { + type: 'string', + description: + 'Display name for the view, e.g. "Overdue". Defaults to "View N" when omitted. References always use the view id, so the name is purely display.', + }, + tableId: { + type: 'string', + description: "Table ID (tbl_...) from the table's meta.json.", + }, + }, + required: ['tableId'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + '{ viewId, tableId, tableName, view } — view is the created view in the same shape as a views.json entry.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary, including the new view id.', + }, + success: { + type: 'boolean', + description: 'Whether the view was created.', + }, + }, + required: ['success', 'message'], + }, + }, create_workflow: { parameters: { type: 'object', @@ -2201,6 +2282,86 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + edit_table_view: { + parameters: { + type: 'object', + properties: { + config: { + type: 'object', + description: + "Configuration parts to replace, in the same shape as an entry of the table's views.json. Each part you include replaces the saved one; omitted parts are kept.", + properties: { + filter: { + type: ['object', 'null'], + description: + 'Row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit to keep the saved filter; null to clear it.', + }, + hiddenColumns: { + type: 'array', + description: + 'Column names hidden in the UI while this view is active; replaces the saved list (pass [] to unhide everything). Display-only — queries through the view still return every column.', + items: { + type: 'string', + }, + }, + sort: { + type: ['array', 'null'], + description: + 'Ordered sort spec, e.g. [{"field":"due","direction":"asc"}], with column NAMES. Omit to keep the saved sort; null to clear it.', + items: { + type: 'object', + properties: { + direction: { + type: 'string', + description: 'Sort direction for this column.', + enum: ['asc', 'desc'], + }, + field: { + type: 'string', + description: 'Exact column name to sort by.', + }, + }, + required: ['field', 'direction'], + }, + }, + }, + }, + isDefault: { + type: 'boolean', + description: + "true makes this view the table's default (clearing the previous default); false demotes it. Omit to leave the flag as it is.", + }, + name: { + type: 'string', + description: 'New display name for the view. Omit to keep the current name.', + }, + viewId: { + type: 'string', + description: "View ID from the table's views.json.", + }, + }, + required: ['viewId'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + '{ viewId, tableId, tableName, view } — view is the updated view in the same shape as a views.json entry.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the view was updated.', + }, + }, + required: ['success', 'message'], + }, + }, edit_workflow: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/request/session/contract.test.ts b/apps/sim/lib/copilot/request/session/contract.test.ts index 86dcbc17fb4..3b32ff8a2f4 100644 --- a/apps/sim/lib/copilot/request/session/contract.test.ts +++ b/apps/sim/lib/copilot/request/session/contract.test.ts @@ -227,3 +227,32 @@ describe('stream session contract parser', () => { expect(parsed.reason).toBe('invalid_json') }) }) + +describe('resource event view pins', () => { + it('accepts a table resource pinned to a saved view', () => { + const event = { + ...BASE_ENVELOPE, + type: 'resource' as const, + payload: { + op: 'upsert' as const, + resource: { id: 'tbl-1', type: 'table', title: 'Invoices', viewId: 'view-1' }, + }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(true) + expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true) + }) + + it('rejects a pin that is not a string', () => { + const event = { + ...BASE_ENVELOPE, + type: 'resource' as const, + payload: { + op: 'upsert' as const, + resource: { id: 'tbl-1', type: 'table', title: 'Invoices', viewId: 42 }, + }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(false) + }) +}) diff --git a/apps/sim/lib/copilot/request/session/contract.ts b/apps/sim/lib/copilot/request/session/contract.ts index bf8c3ea88d6..a514b285b13 100644 --- a/apps/sim/lib/copilot/request/session/contract.ts +++ b/apps/sim/lib/copilot/request/session/contract.ts @@ -273,7 +273,11 @@ function isValidResourcePayload(payload: JsonRecord): boolean { // Dropping a blank id here is the only guard covering both branches // downstream: the handler adds a suppressed file resource to the tab strip // directly, bypassing the checks in `addResource`. - return hasAddressableId(resource.id) && typeof resource.type === 'string' + return ( + hasAddressableId(resource.id) && + typeof resource.type === 'string' && + (resource.viewId === undefined || typeof resource.viewId === 'string') + ) } function isValidRunPayload(payload: JsonRecord): boolean { diff --git a/apps/sim/lib/copilot/request/tools/resources.ts b/apps/sim/lib/copilot/request/tools/resources.ts index 88ee1f01a07..ef0de8290d0 100644 --- a/apps/sim/lib/copilot/request/tools/resources.ts +++ b/apps/sim/lib/copilot/request/tools/resources.ts @@ -118,6 +118,8 @@ export async function handleResourceSideEffects( ...(projectedResources[index].path !== undefined ? { path: projectedResources[index].path } : {}), + // An id, never secret material — read from the raw result. + ...(resource.viewId !== undefined ? { viewId: resource.viewId } : {}), })) : [] @@ -141,7 +143,12 @@ export async function handleResourceSideEffects( type: MothershipStreamV1EventType.resource, payload: { op: MothershipStreamV1ResourceOp.upsert, - resource: { type: resource.type, id: resource.id, title: resource.title }, + resource: { + type: resource.type, + id: resource.id, + title: resource.title, + ...(resource.viewId !== undefined ? { viewId: resource.viewId } : {}), + }, }, }) } diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index c47413711f2..e1e51054ecd 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -194,3 +194,39 @@ describe('extractDeletedResourcesFromToolResult', () => { ).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }]) }) }) + +describe('extractResourcesFromToolResult for the view tools', () => { + it.each(['create_table_view', 'edit_table_view'])( + '%s opens the table pinned to the view it touched', + (toolName) => { + const resources = extractResourcesFromToolResult( + toolName, + { tableId: 'tbl_1' }, + { + success: true, + message: 'Created view "Overdue" (view_1) on table "Invoices"', + data: { + viewId: 'view_1', + tableId: 'tbl_1', + tableName: 'Invoices', + view: { id: 'view_1', name: 'Overdue', isDefault: false, filter: null, sort: null }, + }, + } + ) + + expect(resources).toEqual([ + { type: 'table', id: 'tbl_1', title: 'Invoices', viewId: 'view_1' }, + ]) + } + ) + + it('yields nothing for a failed view call, which names no table', () => { + expect( + extractResourcesFromToolResult( + 'edit_table_view', + { viewId: 'view_1' }, + { success: false, message: 'viewId is required' } + ) + ).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 2f47680dfaf..acb032841d9 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,8 +1,10 @@ import { toRecord } from '@sim/utils/object' import { CreateEmptyFile, + CreateTableView, CreateWorkflow, DownloadFile, + EditTableView, EditWorkflow, Ffmpeg, GenerateAudio, @@ -27,6 +29,8 @@ const RESOURCE_TOOL_NAMES: Set = new Set([ DownloadFile.id, CreateWorkflow.id, EditWorkflow.id, + CreateTableView.id, + EditTableView.id, RunFunction.id, ManageKnowledgeBase.id, Knowledge.id, @@ -196,6 +200,23 @@ export function extractResourcesFromToolResult( return [] } + // The view tools name their table AND the view they touched, so the panel + // opens the table pinned to that view rather than its default. + case CreateTableView.id: + case EditTableView.id: { + const tableId = data.tableId + if (typeof tableId !== 'string' || !tableId) return [] + const viewId = data.viewId + return [ + { + type: 'table', + id: tableId, + title: (data.tableName as string) || 'Table', + ...(typeof viewId === 'string' && viewId ? { viewId } : {}), + }, + ] + } + case Knowledge.id: { const action = data.action as string | undefined if (READ_ONLY_KNOWLEDGE_ACTIONS.has(action ?? '')) return [] diff --git a/apps/sim/lib/copilot/resources/persistence.ts b/apps/sim/lib/copilot/resources/persistence.ts index f4e0e98251b..c47ad6200ef 100644 --- a/apps/sim/lib/copilot/resources/persistence.ts +++ b/apps/sim/lib/copilot/resources/persistence.ts @@ -3,7 +3,7 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { eq, sql } from 'drizzle-orm' -import { GENERIC_RESOURCE_TITLES, type MothershipResource, sanitizeChatResources } from './types' +import { type MothershipResource, mergeChatResource, sanitizeChatResources } from './types' export { extractDeletedResourcesFromToolResult, @@ -51,13 +51,7 @@ export async function persistChatResources( for (const r of sanitizeChatResources(toMerge)) { const key = `${r.type}:${r.id}` - const prev = map.get(key) - if ( - !prev || - (GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(r.title)) - ) { - map.set(key, r) - } + map.set(key, mergeChatResource(map.get(key), r)) } const merged = Array.from(map.values()) diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/copilot/resources/types.test.ts index fa142c19298..1dfba004d98 100644 --- a/apps/sim/lib/copilot/resources/types.test.ts +++ b/apps/sim/lib/copilot/resources/types.test.ts @@ -8,6 +8,7 @@ import { isEphemeralResource, type MothershipResource, MothershipResourceType, + mergeChatResource, PERSISTED_RESOURCE_TYPES, sanitizeChatResources, TERMINAL_SESSION_RESOURCE_ID, @@ -153,3 +154,32 @@ describe('unaddressable resources', () => { expect(parsed.success).toBe(false) }) }) + +describe('mergeChatResource', () => { + const stored = resource({ type: 'table', id: 'tbl-1', title: 'Invoices' }) + + it('adds a resource the chat does not have yet', () => { + expect(mergeChatResource(undefined, stored)).toBe(stored) + }) + + it('keeps the stored entry when the newcomer changes nothing', () => { + expect(mergeChatResource(stored, { ...stored })).toBe(stored) + }) + + it('replaces a placeholder title but never a specific one', () => { + const placeholder = resource({ type: 'table', id: 'tbl-1', title: 'Table' }) + expect(mergeChatResource(placeholder, stored).title).toBe('Invoices') + expect(mergeChatResource(stored, placeholder).title).toBe('Invoices') + }) + + it('moves the pin to the view the agent touched last and keeps it across unpinned re-adds', () => { + const pinnedA = mergeChatResource(stored, { ...stored, viewId: 'view-a' }) + expect(pinnedA.viewId).toBe('view-a') + + const pinnedB = mergeChatResource(pinnedA, { ...stored, viewId: 'view-b' }) + expect(pinnedB.viewId).toBe('view-b') + + // A row edit re-adds the table without a view — the tab stays on view-b. + expect(mergeChatResource(pinnedB, stored)).toBe(pinnedB) + }) +}) diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 58d68d6e737..0092e2eb98c 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -212,6 +212,27 @@ export const GENERIC_RESOURCE_TITLES = new Set([ 'Log', ]) +/** + * Folds a re-added resource into the stored entry with the same type+id. The + * stored title wins unless it was a placeholder. A table's saved-view pin is + * replaced when the newcomer carries one — the tab reopens on the view the + * agent touched last — and kept when it does not, so an unrelated row edit + * never unpins the tab. + */ +export function mergeChatResource( + prev: MothershipResource | undefined, + next: MothershipResource +): MothershipResource { + if (!prev) return next + const title = + GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(next.title) + ? next.title + : prev.title + const viewId = next.viewId ?? prev.viewId + if (title === prev.title && viewId === prev.viewId) return prev + return { ...prev, title, ...(viewId !== undefined ? { viewId } : {}) } +} + export const VFS_DIR_TO_RESOURCE: Record = { tables: 'table', files: 'file', diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 025a1195123..cbeb10ffab3 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -4,7 +4,9 @@ import { z } from 'zod' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { CreateEmptyFile, + CreateTableView, DownloadFile, + EditTableView, Ffmpeg, GenerateAudio, GenerateImage, @@ -49,6 +51,8 @@ import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' +import { createTableViewServerTool } from '@/lib/copilot/tools/server/table/create-table-view' +import { editTableViewServerTool } from '@/lib/copilot/tools/server/table/edit-table-view' import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' @@ -154,6 +158,9 @@ const WRITE_ACTIONS: Record = { [GenerateVideo.id]: ['generate'], [GenerateAudio.id]: ['generate'], [Ffmpeg.id]: ['*'], + // Saved-view create/edit are writes on the table regardless of arguments. + [CreateTableView.id]: ['*'], + [EditTableView.id]: ['*'], // Paid external-provider lookups (hosted-key cost), like the media tools. [enrichmentRunServerTool.name]: ['*'], } @@ -187,6 +194,8 @@ const baseServerToolRegistry: Record = { [tableAutomationsServerTool.name]: tableAutomationsServerTool, [tableEnrichmentsServerTool.name]: tableEnrichmentsServerTool, [tableViewsServerTool.name]: tableViewsServerTool, + [createTableViewServerTool.name]: createTableViewServerTool, + [editTableViewServerTool.name]: editTableViewServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts b/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts new file mode 100644 index 00000000000..c354b8feda9 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useCases = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), +})) + +vi.mock('@/lib/table/application/views', () => ({ + listTableViewsUseCase: { operation: { id: 'tables.views.list' }, execute: useCases.list }, + createTableViewUseCase: { operation: { id: 'tables.views.create' }, execute: useCases.create }, +})) + +const executeUseCase = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ + executeCopilotTableUseCase: executeUseCase, +})) + +import { createTableViewServerTool } from '@/lib/copilot/tools/server/table/create-table-view' +import { createTableViewUseCase, listTableViewsUseCase } from '@/lib/table/application/views' + +const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never + +const columns = [ + { id: 'col_a', name: 'status', type: 'string' }, + { id: 'col_b', name: 'due', type: 'date' }, +] +const table = { id: 'tbl-1', name: 'Invoices', schema: { columns } } + +describe('create_table_view', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates a name-translated view in one call and names the table for the panel', async () => { + executeUseCase + .mockResolvedValueOnce({ table, views: [{ id: 'view-0' }] }) + .mockResolvedValueOnce({ + table, + view: { + id: 'view-1', + name: 'Overdue', + isDefault: false, + config: { + filter: { all: [{ field: 'col_a', op: 'ne', value: 'Done' }] }, + sort: [{ field: 'col_b', direction: 'asc' }], + }, + }, + }) + + const result = await createTableViewServerTool.execute( + { + tableId: 'tbl-1', + name: 'Overdue', + config: { + filter: { all: [{ field: 'status', op: 'ne', value: 'Done' }] }, + sort: [{ field: 'due', direction: 'asc' }], + }, + }, + context + ) + + expect(executeUseCase).toHaveBeenNthCalledWith( + 1, + context, + listTableViewsUseCase, + { tableId: 'tbl-1', workspaceId: 'ws-1' }, + { tableId: 'tbl-1' } + ) + expect(executeUseCase).toHaveBeenNthCalledWith( + 2, + context, + createTableViewUseCase, + { + tableId: 'tbl-1', + workspaceId: 'ws-1', + name: 'Overdue', + config: { + filter: { all: [{ field: 'col_a', op: 'ne', value: 'Done' }] }, + sort: [{ field: 'col_b', direction: 'asc' }], + }, + isDefault: undefined, + }, + { tableId: 'tbl-1' } + ) + expect(result.success).toBe(true) + expect(result.message).toContain('view-1') + expect(result.data).toEqual({ + viewId: 'view-1', + tableId: 'tbl-1', + tableName: 'Invoices', + view: { + id: 'view-1', + name: 'Overdue', + isDefault: false, + filter: { all: [{ field: 'status', op: 'ne', value: 'Done' }] }, + sort: [{ field: 'due', direction: 'asc' }], + hiddenColumns: undefined, + }, + }) + }) + + it('numbers an unnamed view after the ones the table already has and passes isDefault through', async () => { + executeUseCase + .mockResolvedValueOnce({ table, views: [{ id: 'view-0' }, { id: 'view-1' }] }) + .mockResolvedValueOnce({ + table, + view: { id: 'view-2', name: 'View 3', isDefault: true, config: {} }, + }) + + const result = await createTableViewServerTool.execute( + { tableId: 'tbl-1', isDefault: true }, + context + ) + + expect(executeUseCase).toHaveBeenNthCalledWith( + 2, + context, + createTableViewUseCase, + { tableId: 'tbl-1', workspaceId: 'ws-1', name: 'View 3', config: {}, isDefault: true }, + { tableId: 'tbl-1' } + ) + expect(result.success).toBe(true) + expect(result.message).toContain('as its default') + expect(result.data?.view.isDefault).toBe(true) + }) + + it('refuses without a table id and without workspace context', async () => { + expect(await createTableViewServerTool.execute({ tableId: ' ' }, context)).toEqual({ + success: false, + message: 'tableId is required', + }) + expect( + await createTableViewServerTool.execute({ tableId: 'tbl-1' }, { userId: 'user-1' } as never) + ).toEqual({ success: false, message: 'Workspace ID is required' }) + expect(executeUseCase).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/table/create-table-view.ts b/apps/sim/lib/copilot/tools/server/table/create-table-view.ts new file mode 100644 index 00000000000..058c250ae86 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/create-table-view.ts @@ -0,0 +1,62 @@ +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import { CreateTableView } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { + presentTableView, + type TableViewToolConfig, + type TableViewToolResult, + viewToolConfigToPatch, +} from '@/lib/copilot/tools/server/table/view-tool-shared' +import type { TableSchema } from '@/lib/table' +import { createTableViewUseCase, listTableViewsUseCase } from '@/lib/table/application/views' + +interface CreateTableViewArgs { + tableId?: string + name?: string + config?: TableViewToolConfig + isDefault?: boolean +} + +/** + * The main agent's direct path to a new saved view (the table subagent goes + * through table_views). One list read supplies the columns for name→id + * translation and the count behind the default name; the create then lands in + * a single transaction, default flag included. The result names the table so + * resource extraction opens the panel pinned to the new view. + */ +export const createTableViewServerTool: BaseServerTool = { + name: CreateTableView.id, + async execute(params, context) { + const tableId = params?.tableId?.trim() + const workspaceId = context?.workspaceId + if (!tableId) return { success: false, message: 'tableId is required' } + if (!workspaceId) return { success: false, message: 'Workspace ID is required' } + + const listed = await executeCopilotTableUseCase( + context, + listTableViewsUseCase, + { tableId, workspaceId }, + { tableId } + ) + const columns = (listed.table.schema as TableSchema).columns + const name = params.name?.trim() || `View ${listed.views.length + 1}` + const created = await executeCopilotTableUseCase( + context, + createTableViewUseCase, + { + tableId, + workspaceId, + name, + config: viewToolConfigToPatch(params.config ?? {}, columns), + isDefault: params.isDefault, + }, + { tableId } + ) + const view = presentTableView(created.view, columns) + return { + success: true, + message: `Created view "${view.name}" (${view.id}) on table "${created.table.name}"${view.isDefault ? ' as its default' : ''}`, + data: { viewId: view.id, tableId: created.table.id, tableName: created.table.name, view }, + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/edit-table-view.test.ts b/apps/sim/lib/copilot/tools/server/table/edit-table-view.test.ts new file mode 100644 index 00000000000..8672188c25a --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/edit-table-view.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useCases = vi.hoisted(() => ({ + readById: vi.fn(), + update: vi.fn(), +})) + +vi.mock('@/lib/table/application/views', () => ({ + readTableViewByIdUseCase: { operation: { id: 'tables.views.read' }, execute: useCases.readById }, + updateTableViewUseCase: { operation: { id: 'tables.views.update' }, execute: useCases.update }, +})) + +const executeUseCase = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ + executeCopilotTableUseCase: executeUseCase, +})) + +import { editTableViewServerTool } from '@/lib/copilot/tools/server/table/edit-table-view' +import { readTableViewByIdUseCase, updateTableViewUseCase } from '@/lib/table/application/views' + +const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never + +const columns = [ + { id: 'col_a', name: 'status', type: 'string' }, + { id: 'col_b', name: 'due', type: 'date' }, +] +const table = { id: 'tbl-1', name: 'Invoices', schema: { columns } } +const storedView = { + id: 'view-1', + name: 'Overdue', + isDefault: false, + config: { sort: [{ field: 'col_b', direction: 'asc' }] }, +} + +describe('edit_table_view', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves the table from the view id, then patches only the parts sent', async () => { + executeUseCase + .mockResolvedValueOnce({ table, view: storedView, columns }) + .mockResolvedValueOnce({ + table, + view: { + ...storedView, + config: { + filter: { all: [{ field: 'col_a', op: 'eq', value: 'Open' }] }, + sort: [{ field: 'col_b', direction: 'asc' }], + }, + }, + }) + + const result = await editTableViewServerTool.execute( + { + viewId: 'view-1', + config: { filter: { all: [{ field: 'status', op: 'eq', value: 'Open' }] } }, + }, + context + ) + + expect(executeUseCase).toHaveBeenNthCalledWith(1, context, readTableViewByIdUseCase, { + viewId: 'view-1', + workspaceId: 'ws-1', + }) + // No `sort` key at all: the patch is shallow-merged server-side, so a + // present-but-null sort would wipe the saved one. + expect(executeUseCase).toHaveBeenNthCalledWith( + 2, + context, + updateTableViewUseCase, + { + tableId: 'tbl-1', + workspaceId: 'ws-1', + viewId: 'view-1', + name: undefined, + configPatch: { filter: { all: [{ field: 'col_a', op: 'eq', value: 'Open' }] } }, + isDefault: undefined, + }, + { tableId: 'tbl-1' } + ) + expect(result.success).toBe(true) + expect(result.data).toEqual({ + viewId: 'view-1', + tableId: 'tbl-1', + tableName: 'Invoices', + view: { + id: 'view-1', + name: 'Overdue', + isDefault: false, + filter: { all: [{ field: 'status', op: 'eq', value: 'Open' }] }, + sort: [{ field: 'due', direction: 'asc' }], + hiddenColumns: undefined, + }, + }) + }) + + it('renames or promotes without touching the config', async () => { + executeUseCase + .mockResolvedValueOnce({ table, view: storedView, columns }) + .mockResolvedValueOnce({ table, view: { ...storedView, name: 'Late', isDefault: true } }) + + const result = await editTableViewServerTool.execute( + { viewId: 'view-1', name: 'Late', isDefault: true, config: {} }, + context + ) + + const updateInput = executeUseCase.mock.calls[1][2] + expect(updateInput).toEqual({ + tableId: 'tbl-1', + workspaceId: 'ws-1', + viewId: 'view-1', + name: 'Late', + isDefault: true, + }) + expect(updateInput).not.toHaveProperty('configPatch') + expect(result.message).toBe('Updated view "Late" on table "Invoices"') + }) + + it('refuses a call that names nothing to change, before any lookup', async () => { + const result = await editTableViewServerTool.execute({ viewId: 'view-1', config: {} }, context) + + expect(result.success).toBe(false) + expect(result.message).toMatch(/Nothing to change/) + expect(executeUseCase).not.toHaveBeenCalled() + }) + + it('refuses without a view id and without workspace context', async () => { + expect(await editTableViewServerTool.execute({ viewId: '', name: 'x' }, context)).toEqual({ + success: false, + message: 'viewId is required', + }) + expect( + await editTableViewServerTool.execute({ viewId: 'view-1', name: 'x' }, { + userId: 'user-1', + } as never) + ).toEqual({ success: false, message: 'Workspace ID is required' }) + expect(executeUseCase).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts b/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts new file mode 100644 index 00000000000..c98c267eca5 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts @@ -0,0 +1,73 @@ +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import { EditTableView } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { + hasViewConfigParts, + presentTableView, + type TableViewToolConfig, + type TableViewToolResult, + viewToolConfigToPatch, +} from '@/lib/copilot/tools/server/table/view-tool-shared' +import type { TableSchema } from '@/lib/table' +import { readTableViewByIdUseCase, updateTableViewUseCase } from '@/lib/table/application/views' + +interface EditTableViewArgs { + viewId?: string + name?: string + config?: TableViewToolConfig + isDefault?: boolean +} + +/** + * The main agent's direct path to changing a saved view by view id alone. The + * id-addressed read resolves (and authorizes against) the owning table, which + * also supplies the columns the config patch is translated with; the update + * then runs as the ordinary table-scoped mutation. Config parts are + * replace-or-keep, so a filter change never clears the saved sort. + */ +export const editTableViewServerTool: BaseServerTool = { + name: EditTableView.id, + async execute(params, context) { + const viewId = params?.viewId?.trim() + const workspaceId = context?.workspaceId + if (!viewId) return { success: false, message: 'viewId is required' } + if (!workspaceId) return { success: false, message: 'Workspace ID is required' } + + const name = typeof params.name === 'string' ? params.name : undefined + const config = + params.config !== undefined && hasViewConfigParts(params.config) ? params.config : undefined + if (name === undefined && config === undefined && params.isDefault === undefined) { + return { + success: false, + message: + 'Nothing to change — pass name, config (filter, sort, hiddenColumns), and/or isDefault', + } + } + + const resolved = await executeCopilotTableUseCase(context, readTableViewByIdUseCase, { + viewId, + workspaceId, + }) + const tableId = resolved.table.id + const columns = (resolved.table.schema as TableSchema).columns + const updated = await executeCopilotTableUseCase( + context, + updateTableViewUseCase, + { + tableId, + workspaceId, + viewId, + name, + ...(config ? { configPatch: viewToolConfigToPatch(config, columns) } : {}), + isDefault: params.isDefault, + }, + { tableId } + ) + const view = presentTableView(updated.view, columns) + return { + success: true, + message: `Updated view "${view.name}" on table "${updated.table.name}"`, + data: { viewId: view.id, tableId, tableName: updated.table.name, view }, + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-views.ts b/apps/sim/lib/copilot/tools/server/table/table-views.ts index 7b004bb1b99..023607b67ac 100644 --- a/apps/sim/lib/copilot/tools/server/table/table-views.ts +++ b/apps/sim/lib/copilot/tools/server/table/table-views.ts @@ -1,7 +1,12 @@ import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' import { TableViews } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import type { SortSpec, TablePredicateInput, TableSchema, TableViewConfig } from '@/lib/table' +import { + presentTableView, + type TableViewToolConfig, + viewToolConfigToPatch, +} from '@/lib/copilot/tools/server/table/view-tool-shared' +import type { TableSchema, TableViewConfig } from '@/lib/table' import { createTableViewUseCase, deleteTableViewUseCase, @@ -9,7 +14,6 @@ import { readTableViewUseCase, updateTableViewUseCase, } from '@/lib/table/application/views' -import { viewConfigIdsToNames, viewConfigNamesToIds } from '@/lib/table/views/service' type TableViewsArgs = { operation: string @@ -39,32 +43,8 @@ export const tableViewsServerTool: BaseServerTool { - const named = viewConfigIdsToNames(view.config, columns) - return { - id: view.id, - name: view.name, - isDefault: view.isDefault, - filter: named.filter ?? null, - sort: named.sort ?? null, - hiddenColumns: named.hiddenColumns?.length ? named.hiddenColumns : undefined, - } - } - - // Build the patch from only the keys the caller actually sent: the update - // path shallow-merges this into the stored config, so including an absent - // part as `null` silently wiped a view's saved sort when only the filter - // changed (and vice versa) — the doc promises "omit to keep". - const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig => { - const patch: Record = {} - if (args.filter !== undefined) patch.filter = args.filter as TablePredicateInput | null - if (args.sort !== undefined) patch.sort = args.sort as SortSpec | null - if (args.hiddenColumns !== undefined) patch.hiddenColumns = args.hiddenColumns as string[] - return viewConfigNamesToIds(patch as TableViewConfig, columns) - } + const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig => + viewToolConfigToPatch(args as TableViewToolConfig, columns) switch (operation) { case 'list_views': { @@ -75,7 +55,7 @@ export const tableViewsServerTool: BaseServerTool presentView(view, columns)) + const views = result.views.map((view) => presentTableView(view, columns)) return { success: true, message: `Table has ${views.length} view(s)`, @@ -94,7 +74,7 @@ export const tableViewsServerTool: BaseServerTool + +/** Result envelope shared by create_table_view and edit_table_view. */ +export interface TableViewToolResult { + success: boolean + message: string + data?: { + viewId: string + tableId: string + tableName: string + view: PresentedTableView + } +} + +/** Whether a config argument names at least one part to write. */ +export function hasViewConfigParts(config: TableViewToolConfig): boolean { + return ( + config.filter !== undefined || config.sort !== undefined || config.hiddenColumns !== undefined + ) +} + +/** + * Builds the stored (id-domain) config from only the keys the caller sent. The + * update path shallow-merges the result into the stored config, so an absent + * part must stay absent — sending it as `null` silently wiped a view's saved + * sort when only the filter changed (and vice versa); the docs promise "omit to + * keep". Unknown column names are rejected by the translation. + */ +export function viewToolConfigToPatch( + config: TableViewToolConfig, + columns: TableSchema['columns'] +): TableViewConfig { + const patch: Record = {} + if (config.filter !== undefined) patch.filter = config.filter + if (config.sort !== undefined) patch.sort = config.sort + if (config.hiddenColumns !== undefined) patch.hiddenColumns = config.hiddenColumns + return viewConfigNamesToIds(patch as TableViewConfig, columns) +} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 253dd9d94d5..a50d5c61eda 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -722,6 +722,21 @@ describe('resource-naming titles', () => { expect(getToolDisplayTitle('table_rows', { operation: 'update' })).toBe('Updating rows') }) + it('names the view the direct view tools create or edit', () => { + expect( + getToolDisplayTitle('create_table_view', { + tableId: 'tbl_1', + name: 'Overdue', + tableName: 'Invoices', + }) + ).toBe('Creating view Overdue in Invoices') + expect(getToolDisplayTitle('create_table_view', { tableId: 'tbl_1' })).toBe('Creating view') + expect(getToolDisplayTitle('edit_table_view', { viewId: 'view_1', name: 'Late' })).toBe( + 'Editing view Late' + ) + expect(getToolDisplayTitle('edit_table_view', { viewId: 'view_1' })).toBe('Editing view') + }) + it('names the block behind a block-schema read', () => { expect(getToolDisplayTitle('read', { path: 'components/blocks/slack_v2.json' })).toBe( 'Loading Slack' diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 947f0845854..7b4c5238aab 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -128,6 +128,20 @@ function splitTableTitle(name: string, args: ToolArgs): string { } } +/** + * Titles for the direct view tools. create_table_view carries the table id, so + * enrichment can name the table; edit_table_view addresses the view alone. + */ +function tableViewToolTitle(name: string, args: ToolArgs): string { + const view = stringArg(args, 'name') + const suffix = view ? ` ${view}` : '' + if (name === 'create_table_view') { + const table = stringArg(args, 'tableName') + return `Creating view${suffix}${table ? ` in ${table}` : ''}` + } + return `Editing view${suffix}` +} + function deploymentTitle(args: ToolArgs, deploymentType: string): string { const verb = stringArg(args, 'action') === 'undeploy' ? 'Undeploying' : 'Deploying' const workflow = firstStringArg(args, 'workflowName', 'name', 'title') @@ -546,6 +560,8 @@ const TOOL_TITLES: Record = { table_automations: 'Wiring automation', table_enrichments: 'Configuring enrichment', table_views: 'Editing views', + create_table_view: 'Creating view', + edit_table_view: 'Editing view', prepare_file_edit: 'Editing file', apply_file_edit: 'Writing changes', create_workflow: 'Creating workflow', @@ -825,6 +841,9 @@ export function getToolDisplayTitle(name: string, args?: Record case 'table_enrichments': case 'table_views': return splitTableTitle(name, args) + case 'create_table_view': + case 'edit_table_view': + return tableViewToolTitle(name, args) case 'search_knowledge_base': return searchKnowledgeBaseTitle(args) case 'manage_sandbox': diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 212a8ad674d..82d9f245e09 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1367,7 +1367,7 @@ export function serializeTableViews( hiddenColumns: view.hiddenColumns?.length ? view.hiddenColumns : undefined, updatedAt: view.updatedAt instanceof Date ? view.updatedAt.toISOString() : view.updatedAt, })), - note: 'Query a view via query_user_table {operation: "query_rows", args: {tableId, view: ""}} — the saved filter ANDs with any extra filter you pass. Manage views via the table agent (table_views).', + note: 'Query a view via query_user_table {operation: "query_rows", args: {tableId, view: ""}} — the saved filter ANDs with any extra filter you pass. Create or change a view with create_table_view / edit_table_view (main agent) or table_views (table agent).', }, null, 2 diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts index 9ad2abd3247..f8a59699c3f 100644 --- a/apps/sim/lib/table/application/context.ts +++ b/apps/sim/lib/table/application/context.ts @@ -1,6 +1,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { getTableById, type TableDefinition } from '@/lib/table' import type { TableAuthorizationContext } from '@/lib/table/application/authorization' +import { getTableViewTableId } from '@/lib/table/views/service' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export type TableWorkspaceContext = TableAuthorizationContext @@ -119,3 +120,33 @@ export async function resolveArchivedTableContext(input: { const workspaceContext = await resolveTableWorkspaceContext(table.workspaceId) return { ...workspaceContext, tableId: table.id, table } } + +export interface ActiveTableViewContext extends ActiveTableContext { + viewId: string +} + +/** + * Loads the canonical table context for a caller that holds only a view id. + * + * The view lookup is workspace-scoped, so a view in another workspace reports as `not_found` + * before any table is loaded — the same concealment {@link resolveActiveTableContext} applies to + * a mismatched table id. The resolved table id then goes through that resolver unchanged, so both + * paths authorize against the identical context. + */ +export async function resolveActiveTableViewContext(input: { + viewId: string + assertedWorkspaceId: string +}): Promise { + const tableId = await getTableViewTableId(input.viewId, input.assertedWorkspaceId) + if (!tableId) { + throw new OrchestrationError( + 'not_found', + `View "${input.viewId}" not found in this workspace — view ids are listed in each table's views.json.` + ) + } + const context = await resolveActiveTableContext({ + tableId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + return { ...context, viewId: input.viewId } +} diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts index eea6e60128f..e7bc57f7a22 100644 --- a/apps/sim/lib/table/application/views.ts +++ b/apps/sim/lib/table/application/views.ts @@ -3,7 +3,10 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { TableSchema, TableViewConfig } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' -import { resolveActiveTableContext } from '@/lib/table/application/context' +import { + resolveActiveTableContext, + resolveActiveTableViewContext, +} from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' import { createTableView, @@ -65,9 +68,41 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({ }, }) +export interface ReadTableViewByIdInput { + viewId: string + workspaceId: string +} + +/** + * Reads a view addressed by id alone. The owning table is resolved from the view + * (workspace-scoped), so a caller holding only a view id — the agent's + * edit_table_view — reaches the same table-scoped authorization as the tableId + * variant, and gets the table back to address the write that follows. + */ +export const readTableViewByIdUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.readView, + resolveContext: ({ input }: { input: ReadTableViewByIdInput }) => + resolveActiveTableViewContext({ + viewId: input.viewId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ context }) { + const columns = (context.table.schema as TableSchema).columns + const view = await getTableView(context.viewId, context.table.id, columns, context.workspaceId) + if (!view) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — list the views on this table for valid view ids' + ) + return { view, columns, table: context.table } + }, +}) + export interface CreateTableViewInput extends TableViewInput { name: string config: TableViewConfig + /** Make the new view the table's default, demoting the previous one in the same transaction. */ + isDefault?: boolean } export const createTableViewUseCase = defineAuthorizedTableUseCase({ @@ -88,6 +123,7 @@ export const createTableViewUseCase = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, name: input.name, config: input.config, + isDefault: input.isDefault, userId: attribution.attributedUserId, columns, strictRefs: true, diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 5740cb43ae7..2d2169cfe73 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -18,6 +18,7 @@ import { createTableView, deleteTableView, getTableView, + getTableViewTableId, normalizeStoredViewConfig, pruneViewConfig, updateTableView, @@ -178,6 +179,42 @@ describe('table-view mutations signal collaborators', () => { } ) + it('createTableView with isDefault demotes the current default in the same transaction', async () => { + queueTableRows(tableViews, [{ total: 2 }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, isDefault: true }]) + + await createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: {}, + userId: 'user-1', + columns, + isDefault: true, + }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith({ isDefault: false }) + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ isDefault: true })) + }) + + it('createTableView without isDefault never demotes, even on a first view (which is default anyway)', async () => { + queueTableRows(tableViews, [{ total: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, isDefault: true }]) + + await createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: {}, + userId: 'user-1', + columns, + isDefault: false, + }) + + expect(dbChainMockFns.set).not.toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ isDefault: true })) + }) + it('updateTableView signals when the target view exists', async () => { queueTableRows(tableViews, [{ id: 'view-1' }]) // the in-transaction existence pre-check dbChainMockFns.returning.mockResolvedValueOnce([viewRow]) // the update returning @@ -694,3 +731,19 @@ describe('view config column-reference normalization', () => { ).toEqual([{ field: 'createdAt', direction: 'desc' }]) }) }) + +describe('getTableViewTableId', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('names the table a view belongs to', async () => { + queueTableRows(tableViews, [{ tableId: 'table-1' }]) + expect(await getTableViewTableId('view-1', 'ws-1')).toBe('table-1') + }) + + it('reads a view outside the asserted workspace as missing', async () => { + expect(await getTableViewTableId('view-elsewhere', 'ws-1')).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index dfa4f339b15..5c5b5c2bc89 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -385,6 +385,24 @@ export async function getTableView( return row ? toTableView(row, columns) : null } +/** + * The table a view belongs to, scoped to the workspace the caller asserted so a + * view id from another workspace reads as missing rather than naming its owner. + * Lets a caller holding only a view id (the agent's edit_table_view) reach the + * table-scoped use cases without a lookup surface of its own. + */ +export async function getTableViewTableId( + viewId: string, + workspaceId: string +): Promise { + const [row] = await db + .select({ tableId: tableViews.tableId }) + .from(tableViews) + .where(and(eq(tableViews.id, viewId), eq(tableViews.workspaceId, workspaceId))) + .limit(1) + return row?.tableId ?? null +} + function normalizeName(name: string): string { const trimmed = name.trim() if (!trimmed) throw new TableViewValidationError('View name cannot be empty') @@ -418,6 +436,12 @@ export interface CreateTableViewData { config: TableViewConfig userId: string columns: ColumnDefinition[] + /** + * Make the new view the table's default, demoting the previous default in the + * same transaction. The first view on a table is the default regardless — a + * table that has views always keeps one. + */ + isDefault?: boolean /** * Whether to refuse a filter, sort, or column-layout reference naming no live * column. Set by the `/api/v2` surface only, whose caller authored the config @@ -471,6 +495,21 @@ export async function createTableView(data: CreateTableViewData): Promise 0) { + await trx + .update(tableViews) + .set({ isDefault: false }) + .where( + and( + eq(tableViews.tableId, data.tableId), + eq(tableViews.workspaceId, data.workspaceId), + eq(tableViews.isDefault, true) + ) + ) + } + const [created] = await trx .insert(tableViews) .values({ @@ -479,7 +518,7 @@ export async function createTableView(data: CreateTableViewData): Promise { + beforeEach(() => { + useTableViewPinStore.getState().reset() + }) + + it('keeps one pending pin per table, the latest winning', () => { + const { pin } = useTableViewPinStore.getState() + pin('tbl-1', 'view-a') + pin('tbl-1', 'view-b') + pin('tbl-2', 'view-c') + + const { pins } = useTableViewPinStore.getState() + expect(pins['tbl-1'].viewId).toBe('view-b') + expect(pins['tbl-2'].viewId).toBe('view-c') + }) + + it('re-pinning the same view is a new request, so a re-edit after the user moved on still switches', () => { + const { pin } = useTableViewPinStore.getState() + pin('tbl-1', 'view-a') + const first = useTableViewPinStore.getState().pins['tbl-1'] + pin('tbl-1', 'view-a') + const second = useTableViewPinStore.getState().pins['tbl-1'] + + expect(second.viewId).toBe(first.viewId) + expect(second.seq).toBeGreaterThan(first.seq) + }) + + it('consume clears only the pin it was handed, never a newer one', () => { + const { pin, consume } = useTableViewPinStore.getState() + pin('tbl-1', 'view-a') + const stale = useTableViewPinStore.getState().pins['tbl-1'] + pin('tbl-1', 'view-b') + + consume('tbl-1', stale.seq) + expect(useTableViewPinStore.getState().pins['tbl-1'].viewId).toBe('view-b') + + consume('tbl-1', useTableViewPinStore.getState().pins['tbl-1'].seq) + expect(useTableViewPinStore.getState().pins['tbl-1']).toBeUndefined() + }) + + it('consuming a table with no pin is a no-op', () => { + const before = useTableViewPinStore.getState().pins + useTableViewPinStore.getState().consume('tbl-none', 1) + expect(useTableViewPinStore.getState().pins).toBe(before) + }) +}) diff --git a/apps/sim/stores/table/view-pin/store.ts b/apps/sim/stores/table/view-pin/store.ts new file mode 100644 index 00000000000..d66d3c8e2b8 --- /dev/null +++ b/apps/sim/stores/table/view-pin/store.ts @@ -0,0 +1,55 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +/** A request that the table switch to one of its saved views. */ +export interface TableViewPin { + viewId: string + /** Distinguishes a repeat pin of the same view — a re-edit after the user moved on — from one already honoured. */ + seq: number +} + +interface TableViewPinState { + /** Pending pins keyed by table id. */ + pins: Record + nextSeq: number + /** Asks the table to open on `viewId`; replaces any pin still pending for it. */ + pin: (tableId: string, viewId: string) => void + /** Clears a pin the table has applied. A newer pin (higher seq) issued meanwhile is kept. */ + consume: (tableId: string, seq: number) => void + reset: () => void +} + +const initialState = { pins: {} as Record, nextSeq: 1 } + +/** + * Bridges the agent's saved-view work to the embedded table. A view the agent + * just created or edited arrives on the resource stream before the table's + * views query has refetched, so the switch can't be a plain URL write — the + * table would treat the not-yet-listed id as dead and fall back to its default. + * The pin waits here until the table (mounted now or later) sees the view in + * its list, applies it, and consumes the pin. + * + * Ephemeral — no persistence. Reopening a chat restores a pin from the stored + * resource's `viewId` instead. + */ +export const useTableViewPinStore = create()( + devtools( + (set) => ({ + ...initialState, + pin: (tableId, viewId) => + set((state) => ({ + pins: { ...state.pins, [tableId]: { viewId, seq: state.nextSeq } }, + nextSeq: state.nextSeq + 1, + })), + consume: (tableId, seq) => + set((state) => { + const pending = state.pins[tableId] + if (!pending || pending.seq !== seq) return state + const { [tableId]: _consumed, ...pins } = state.pins + return { pins } + }), + reset: () => set(initialState), + }), + { name: 'table-view-pin-store' } + ) +) From b1cd4a9f128852a883c26075b05cc4182eb135fd Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:37:33 -0700 Subject: [PATCH 02/17] fix(copilot): address review findings on table view tools - edit_table_view resolves the view's table under a workspace-only context (no table scope exists yet for the delegated principal), then re-enters the table-scoped read and update with that id - updateTableView takes the per-table views lock when promoting, so it serializes with default-on-create instead of racing the unique index - the View N fallback is chosen inside the locked create - unknown column names are classified as validation errors in the shared translation, so the model sees which column it got wrong - pending view pins are reset when a chat is torn down or switched - add and reorder share one chat-resource item schema; reorder merges incoming entries with stored ones so pins and paths survive - mergeChatResource keeps every field the newcomer defines - the pin merge runs for every pinned upsert, not gated on wasAdded --- .../app/api/copilot/chat/resources/route.ts | 9 ++- .../stream/handle-resource-event.test.ts | 7 +- .../hooks/stream/handle-resource-event.ts | 21 ++--- .../[workspaceId]/home/hooks/use-chat.ts | 4 + apps/sim/lib/api/contracts/copilot.ts | 27 +++---- apps/sim/lib/copilot/resources/types.test.ts | 24 ++++++ apps/sim/lib/copilot/resources/types.ts | 33 +++++--- .../server/table/create-table-view.test.ts | 27 ++++++- .../tools/server/table/create-table-view.ts | 7 +- .../server/table/edit-table-view.test.ts | 78 +++++++++++++------ .../tools/server/table/edit-table-view.ts | 24 ++++-- .../tools/server/table/view-tool-shared.ts | 20 ++++- apps/sim/lib/table/application/context.ts | 31 -------- apps/sim/lib/table/application/views.ts | 38 ++++----- apps/sim/lib/table/views/service.test.ts | 56 +++++++++++++ apps/sim/lib/table/views/service.ts | 19 ++++- 16 files changed, 294 insertions(+), 131 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 2d4402fd8d0..8e80e559a58 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -135,8 +135,13 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => { const existing = sanitizeChatResources( Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] ) - const canonicalOrder = sanitizeChatResources(newOrder) - const existingKeys = new Set(existing.map((r) => `${r.type}:${r.id}`)) + // The client echoes the tabs it holds; anything it does not carry (a view + // pin, a path) is taken from the stored entry rather than dropped. + const existingByKey = new Map(existing.map((r) => [`${r.type}:${r.id}`, r])) + const canonicalOrder = sanitizeChatResources(newOrder).map((r) => + mergeChatResource(existingByKey.get(`${r.type}:${r.id}`), r) + ) + const existingKeys = new Set(existingByKey.keys()) const newKeys = new Set(canonicalOrder.map((r) => `${r.type}:${r.id}`)) if (existingKeys.size !== newKeys.size || ![...existingKeys].every((k) => newKeys.has(k))) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts index e858d049e28..368b098c1ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts @@ -141,7 +141,12 @@ describe('handleResourceEvent saved-view pins', () => { title: 'Invoices', viewId: 'view-1', }) - expect(deps.setResources).not.toHaveBeenCalled() + // The pin merge always runs; on a list that lacks the table it is a no-op. + const updater = (deps.setResources as ReturnType).mock.calls[0][0] as ( + current: MothershipResource[] + ) => MothershipResource[] + const others: MothershipResource[] = [{ type: 'file', id: 'file-1', title: 'notes.md' }] + expect(updater(others)).toBe(others) expect(useTableViewPinStore.getState().pins['tbl-1']?.viewId).toBe('view-1') expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith( deps.queryClient, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index 9c309e0de8f..1e00012af78 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -122,16 +122,17 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId) } if (pinnedViewId) { - if (!wasAdded) { - // The tab already exists: carry the newest pin so a remount adopts it. - setResources((current) => - current.some((r) => r.type === 'table' && r.id === resource.id && r.viewId !== pinnedViewId) - ? current.map((r) => - r.type === 'table' && r.id === resource.id ? { ...r, viewId: pinnedViewId } : r - ) - : current - ) - } + // Carry the newest pin on an existing tab so a remount adopts it. Not gated + // on `wasAdded`: two upserts in one render both read the stale ref and both + // report "added", while only the first updater actually inserted — the + // updater is idempotent, so it simply runs every time. + setResources((current) => + current.some((r) => r.type === 'table' && r.id === resource.id && r.viewId !== pinnedViewId) + ? current.map((r) => + r.type === 'table' && r.id === resource.id ? { ...r, viewId: pinnedViewId } : r + ) + : current + ) // Consumed by the embedded table once its views list carries the view — // which may be after the refetch below lands, or after the tab first opens. useTableViewPinStore.getState().pin(resource.id, pinnedViewId) 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 c5cdcc96d23..791441458c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -136,6 +136,7 @@ import type { QueuedSendHandoffSeed, } from '@/stores/mothership-queue/types' import type { ChatContext } from '@/stores/panel' +import { useTableViewPinStore } from '@/stores/table/view-pin/store' import { useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -1637,6 +1638,8 @@ export function useChat( setTransportIdle() setResources([]) setActiveResourceId(null) + // Pending view pins belong to the chat whose stream issued them. + useTableViewPinStore.getState().reset() undisplayableResourcesRef.current = [] pendingPersistResourceKeysRef.current.clear() inFlightResourceAddsRef.current.clear() @@ -2339,6 +2342,7 @@ export function useChat( setTransportIdle() setResources([]) setActiveResourceId(null) + useTableViewPinStore.getState().reset() pendingPersistResourceKeysRef.current.clear() inFlightResourceAddsRef.current.clear() reorderNeededAfterFlushRef.current = false diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 209f8990d07..71156a84a40 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -99,16 +99,19 @@ export type RenameCopilotChatBody = z.input const copilotResourceTypeSchema = z.enum(PERSISTED_RESOURCE_TYPES) +const copilotChatResourceItemSchema = z.object({ + type: copilotResourceTypeSchema, + // Matches the bound the chat-send path enforces. + id: requiredFieldSchema('resource.id cannot be empty'), + title: z.string(), + // Saved view a table tab is pinned to (type "table" only). One schema for + // add and reorder, so a reorder round-trip can never strip the pin. + viewId: z.string().min(1).optional(), +}) + export const addCopilotChatResourceBodySchema = z.object({ chatId: z.string(), - resource: z.object({ - type: copilotResourceTypeSchema, - // Matches the bound the chat-send path enforces. - id: requiredFieldSchema('resource.id cannot be empty'), - title: z.string(), - // Saved view a table tab is pinned to (type "table" only). - viewId: z.string().min(1).optional(), - }), + resource: copilotChatResourceItemSchema, }) export type AddCopilotChatResourceBody = z.input @@ -121,13 +124,7 @@ export type RemoveCopilotChatResourceBody = z.input diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/copilot/resources/types.test.ts index 1dfba004d98..99dcf36c43d 100644 --- a/apps/sim/lib/copilot/resources/types.test.ts +++ b/apps/sim/lib/copilot/resources/types.test.ts @@ -183,3 +183,27 @@ describe('mergeChatResource', () => { expect(mergeChatResource(pinnedB, stored)).toBe(pinnedB) }) }) + +describe('mergeChatResource metadata', () => { + it('takes the metadata a newcomer defines and keeps what it omits', () => { + const placeholder = resource({ type: 'file', id: 'f1', title: 'File' }) + const upgraded = mergeChatResource(placeholder, { + type: 'file', + id: 'f1', + title: 'notes.md', + path: 'files/notes.md', + }) + expect(upgraded).toEqual({ type: 'file', id: 'f1', title: 'notes.md', path: 'files/notes.md' }) + + // A later re-add without a path keeps the stored one. + expect(mergeChatResource(upgraded, { type: 'file', id: 'f1', title: 'notes.md' })).toBe( + upgraded + ) + + const log = resource({ type: 'log', id: 'row-1', title: 'Run' }) + expect( + mergeChatResource(log, { type: 'log', id: 'row-1', title: 'Run', executionId: 'exec-1' }) + .executionId + ).toBe('exec-1') + }) +}) diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 0092e2eb98c..3bc585c8235 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -214,23 +214,34 @@ export const GENERIC_RESOURCE_TITLES = new Set([ /** * Folds a re-added resource into the stored entry with the same type+id. The - * stored title wins unless it was a placeholder. A table's saved-view pin is - * replaced when the newcomer carries one — the tab reopens on the view the - * agent touched last — and kept when it does not, so an unrelated row edit - * never unpins the tab. + * stored title wins unless it was a placeholder. Every other field the + * newcomer defines replaces the stored one — a file's `path`, a log's + * `executionId`, a table's saved-view pin (the tab reopens on the view the + * agent touched last) — while a field the newcomer omits is kept, so an + * unrelated row edit never unpins a table. Returns `prev` itself when nothing + * changes, so callers can skip a no-op write. */ export function mergeChatResource( prev: MothershipResource | undefined, next: MothershipResource ): MothershipResource { if (!prev) return next - const title = - GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(next.title) - ? next.title - : prev.title - const viewId = next.viewId ?? prev.viewId - if (title === prev.title && viewId === prev.viewId) return prev - return { ...prev, title, ...(viewId !== undefined ? { viewId } : {}) } + const merged: MothershipResource = { + ...prev, + ...(next.path !== undefined ? { path: next.path } : {}), + ...(next.viewId !== undefined ? { viewId: next.viewId } : {}), + ...(next.executionId !== undefined ? { executionId: next.executionId } : {}), + title: + GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(next.title) + ? next.title + : prev.title, + } + const unchanged = + merged.title === prev.title && + merged.path === prev.path && + merged.viewId === prev.viewId && + merged.executionId === prev.executionId + return unchanged ? prev : merged } export const VFS_DIR_TO_RESOURCE: Record = { diff --git a/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts b/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts index c354b8feda9..04d7427a37b 100644 --- a/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts @@ -20,6 +20,7 @@ vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ })) import { createTableViewServerTool } from '@/lib/copilot/tools/server/table/create-table-view' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { createTableViewUseCase, listTableViewsUseCase } from '@/lib/table/application/views' const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never @@ -103,7 +104,7 @@ describe('create_table_view', () => { }) }) - it('numbers an unnamed view after the ones the table already has and passes isDefault through', async () => { + it('leaves an omitted name to the service (numbered under the lock) and passes isDefault through', async () => { executeUseCase .mockResolvedValueOnce({ table, views: [{ id: 'view-0' }, { id: 'view-1' }] }) .mockResolvedValueOnce({ @@ -112,7 +113,7 @@ describe('create_table_view', () => { }) const result = await createTableViewServerTool.execute( - { tableId: 'tbl-1', isDefault: true }, + { tableId: 'tbl-1', name: ' ', isDefault: true }, context ) @@ -120,14 +121,34 @@ describe('create_table_view', () => { 2, context, createTableViewUseCase, - { tableId: 'tbl-1', workspaceId: 'ws-1', name: 'View 3', config: {}, isDefault: true }, + { tableId: 'tbl-1', workspaceId: 'ws-1', name: undefined, config: {}, isDefault: true }, { tableId: 'tbl-1' } ) expect(result.success).toBe(true) + expect(result.message).toContain('"View 3"') expect(result.message).toContain('as its default') expect(result.data?.view.isDefault).toBe(true) }) + it("classifies an unknown column as the caller's mistake, before any write", async () => { + executeUseCase.mockResolvedValueOnce({ table, views: [] }) + + const failure = await createTableViewServerTool + .execute( + { + tableId: 'tbl-1', + name: 'Urgent', + config: { filter: { all: [{ field: 'priority', op: 'eq', value: 'high' }] } }, + }, + context + ) + .catch((error: unknown) => error) + + expect(asOrchestrationError(failure)?.code).toBe('validation') + expect(asOrchestrationError(failure)?.message).toMatch(/Unknown column\(s\): priority/) + expect(executeUseCase).toHaveBeenCalledTimes(1) + }) + it('refuses without a table id and without workspace context', async () => { expect(await createTableViewServerTool.execute({ tableId: ' ' }, context)).toEqual({ success: false, diff --git a/apps/sim/lib/copilot/tools/server/table/create-table-view.ts b/apps/sim/lib/copilot/tools/server/table/create-table-view.ts index 058c250ae86..9096fa21511 100644 --- a/apps/sim/lib/copilot/tools/server/table/create-table-view.ts +++ b/apps/sim/lib/copilot/tools/server/table/create-table-view.ts @@ -20,8 +20,9 @@ interface CreateTableViewArgs { /** * The main agent's direct path to a new saved view (the table subagent goes * through table_views). One list read supplies the columns for name→id - * translation and the count behind the default name; the create then lands in - * a single transaction, default flag included. The result names the table so + * translation; the create then lands in a single locked transaction — default + * flag and, when no name was given, the `View N` fallback included, so two + * unnamed creates can never pick the same N. The result names the table so * resource extraction opens the panel pinned to the new view. */ export const createTableViewServerTool: BaseServerTool = { @@ -39,7 +40,7 @@ export const createTableViewServerTool: BaseServerTool ({ - readById: vi.fn(), + owner: vi.fn(), + read: vi.fn(), update: vi.fn(), })) vi.mock('@/lib/table/application/views', () => ({ - readTableViewByIdUseCase: { operation: { id: 'tables.views.read' }, execute: useCases.readById }, + resolveTableViewOwnerUseCase: { + operation: { id: 'tables.views.read' }, + execute: useCases.owner, + }, + readTableViewUseCase: { operation: { id: 'tables.views.read' }, execute: useCases.read }, updateTableViewUseCase: { operation: { id: 'tables.views.update' }, execute: useCases.update }, })) @@ -20,7 +25,12 @@ vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ })) import { editTableViewServerTool } from '@/lib/copilot/tools/server/table/edit-table-view' -import { readTableViewByIdUseCase, updateTableViewUseCase } from '@/lib/table/application/views' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { + readTableViewUseCase, + resolveTableViewOwnerUseCase, + updateTableViewUseCase, +} from '@/lib/table/application/views' const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never @@ -36,24 +46,27 @@ const storedView = { config: { sort: [{ field: 'col_b', direction: 'asc' }] }, } +/** owner lookup (workspace scope) → read (table scope) → update (table scope) */ +function queueHappyPath(updatedView: typeof storedView) { + executeUseCase + .mockResolvedValueOnce({ tableId: 'tbl-1' }) + .mockResolvedValueOnce({ table, view: storedView, columns }) + .mockResolvedValueOnce({ table, view: updatedView }) +} + describe('edit_table_view', () => { beforeEach(() => { vi.clearAllMocks() }) - it('resolves the table from the view id, then patches only the parts sent', async () => { - executeUseCase - .mockResolvedValueOnce({ table, view: storedView, columns }) - .mockResolvedValueOnce({ - table, - view: { - ...storedView, - config: { - filter: { all: [{ field: 'col_a', op: 'eq', value: 'Open' }] }, - sort: [{ field: 'col_b', direction: 'asc' }], - }, - }, - }) + it('resolves the table from the view id without a scope, then re-enters table-scoped', async () => { + queueHappyPath({ + ...storedView, + config: { + filter: { all: [{ field: 'col_a', op: 'eq', value: 'Open' }] }, + sort: [{ field: 'col_b', direction: 'asc' }], + }, + }) const result = await editTableViewServerTool.execute( { @@ -63,14 +76,23 @@ describe('edit_table_view', () => { context ) - expect(executeUseCase).toHaveBeenNthCalledWith(1, context, readTableViewByIdUseCase, { + // The delegated principal has no table to scope to yet, so the owner + // lookup must not claim one. + expect(executeUseCase).toHaveBeenNthCalledWith(1, context, resolveTableViewOwnerUseCase, { viewId: 'view-1', workspaceId: 'ws-1', }) + expect(executeUseCase).toHaveBeenNthCalledWith( + 2, + context, + readTableViewUseCase, + { tableId: 'tbl-1', workspaceId: 'ws-1', viewId: 'view-1' }, + { tableId: 'tbl-1' } + ) // No `sort` key at all: the patch is shallow-merged server-side, so a // present-but-null sort would wipe the saved one. expect(executeUseCase).toHaveBeenNthCalledWith( - 2, + 3, context, updateTableViewUseCase, { @@ -100,16 +122,14 @@ describe('edit_table_view', () => { }) it('renames or promotes without touching the config', async () => { - executeUseCase - .mockResolvedValueOnce({ table, view: storedView, columns }) - .mockResolvedValueOnce({ table, view: { ...storedView, name: 'Late', isDefault: true } }) + queueHappyPath({ ...storedView, name: 'Late', isDefault: true }) const result = await editTableViewServerTool.execute( { viewId: 'view-1', name: 'Late', isDefault: true, config: {} }, context ) - const updateInput = executeUseCase.mock.calls[1][2] + const updateInput = executeUseCase.mock.calls[2][2] expect(updateInput).toEqual({ tableId: 'tbl-1', workspaceId: 'ws-1', @@ -121,6 +141,20 @@ describe('edit_table_view', () => { expect(result.message).toBe('Updated view "Late" on table "Invoices"') }) + it("classifies an unknown column as the caller's mistake, before the write", async () => { + executeUseCase + .mockResolvedValueOnce({ tableId: 'tbl-1' }) + .mockResolvedValueOnce({ table, view: storedView, columns }) + + const failure = await editTableViewServerTool + .execute({ viewId: 'view-1', config: { hiddenColumns: ['priority'] } }, context) + .catch((error: unknown) => error) + + expect(asOrchestrationError(failure)?.code).toBe('validation') + expect(asOrchestrationError(failure)?.message).toMatch(/Unknown column\(s\): priority/) + expect(executeUseCase).toHaveBeenCalledTimes(2) + }) + it('refuses a call that names nothing to change, before any lookup', async () => { const result = await editTableViewServerTool.execute({ viewId: 'view-1', config: {} }, context) diff --git a/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts b/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts index c98c267eca5..3a82277270b 100644 --- a/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts +++ b/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts @@ -9,7 +9,11 @@ import { viewToolConfigToPatch, } from '@/lib/copilot/tools/server/table/view-tool-shared' import type { TableSchema } from '@/lib/table' -import { readTableViewByIdUseCase, updateTableViewUseCase } from '@/lib/table/application/views' +import { + readTableViewUseCase, + resolveTableViewOwnerUseCase, + updateTableViewUseCase, +} from '@/lib/table/application/views' interface EditTableViewArgs { viewId?: string @@ -19,10 +23,11 @@ interface EditTableViewArgs { } /** - * The main agent's direct path to changing a saved view by view id alone. The - * id-addressed read resolves (and authorizes against) the owning table, which - * also supplies the columns the config patch is translated with; the update - * then runs as the ordinary table-scoped mutation. Config parts are + * The main agent's direct path to changing a saved view by view id alone. + * Three authorized steps: a workspace-scoped lookup names the owning table + * (the delegated principal has no table scope to offer before that), then the + * table-scoped read supplies the columns the config patch is translated with, + * and the update runs as the ordinary table-scoped mutation. Config parts are * replace-or-keep, so a filter change never clears the saved sort. */ export const editTableViewServerTool: BaseServerTool = { @@ -44,11 +49,16 @@ export const editTableViewServerTool: BaseServerTool { - const tableId = await getTableViewTableId(input.viewId, input.assertedWorkspaceId) - if (!tableId) { - throw new OrchestrationError( - 'not_found', - `View "${input.viewId}" not found in this workspace — view ids are listed in each table's views.json.` - ) - } - const context = await resolveActiveTableContext({ - tableId, - assertedWorkspaceId: input.assertedWorkspaceId, - }) - return { ...context, viewId: input.viewId } -} diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts index e7bc57f7a22..3ae23cd1ef3 100644 --- a/apps/sim/lib/table/application/views.ts +++ b/apps/sim/lib/table/application/views.ts @@ -5,13 +5,14 @@ import type { TableSchema, TableViewConfig } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveActiveTableContext, - resolveActiveTableViewContext, + resolveTableWorkspaceContext, } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' import { createTableView, deleteTableView, getTableView, + getTableViewTableId, listTableViews, TableViewValidationError, updateTableView, @@ -68,38 +69,37 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({ }, }) -export interface ReadTableViewByIdInput { +export interface ResolveTableViewOwnerInput { viewId: string workspaceId: string } /** - * Reads a view addressed by id alone. The owning table is resolved from the view - * (workspace-scoped), so a caller holding only a view id — the agent's - * edit_table_view — reaches the same table-scoped authorization as the tableId - * variant, and gets the table back to address the write that follows. + * Names the table a view belongs to, for a caller holding only a view id (the + * agent's edit_table_view). Authorized at workspace level on purpose: the + * context carries no tableId yet, so a delegated principal needs no table scope + * to ask, and the answer is only an id. The caller then re-enters the + * table-scoped use cases with that id — which is where the table itself, and + * the principal's scope for it, are authorized. */ -export const readTableViewByIdUseCase = defineAuthorizedTableUseCase({ +export const resolveTableViewOwnerUseCase = defineAuthorizedTableUseCase({ operation: tableOperations.readView, - resolveContext: ({ input }: { input: ReadTableViewByIdInput }) => - resolveActiveTableViewContext({ - viewId: input.viewId, - assertedWorkspaceId: input.workspaceId, - }), - async execute({ context }) { - const columns = (context.table.schema as TableSchema).columns - const view = await getTableView(context.viewId, context.table.id, columns, context.workspaceId) - if (!view) + resolveContext: ({ input }: { input: ResolveTableViewOwnerInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + const tableId = await getTableViewTableId(input.viewId, context.workspaceId) + if (!tableId) throw new OrchestrationError( 'not_found', - 'View not found on this table — list the views on this table for valid view ids' + `View "${input.viewId}" not found in this workspace — view ids are listed in each table's views.json.` ) - return { view, columns, table: context.table } + return { tableId } }, }) export interface CreateTableViewInput extends TableViewInput { - name: string + /** Omit to number the view after the ones the table already has (`View N`). */ + name?: string config: TableViewConfig /** Make the new view the table's default, demoting the previous one in the same transaction. */ isDefault?: boolean diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 2d2169cfe73..911a2b80ea2 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -747,3 +747,59 @@ describe('getTableViewTableId', () => { expect(await getTableViewTableId('view-elsewhere', 'ws-1')).toBeNull() }) }) + +describe('default-view writers share the views lock', () => { + const columns: ColumnDefinition[] = [] + const viewRow = { + id: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('numbers an unnamed view after the ones the table has, from the count read under the lock', async () => { + queueTableRows(tableViews, [{ total: 2 }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, name: 'View 3' }]) + + const view = await createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + config: {}, + userId: 'user-1', + columns, + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ name: 'View 3' })) + expect(view.name).toBe('View 3') + }) + + it('promoting a view takes the per-table advisory lock the create path holds', async () => { + queueTableRows(tableViews, [{ id: 'view-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, isDefault: true }]) + + await updateTableView({ viewId: 'view-1', tableId: 'table-1', isDefault: true, columns }) + + // withTableViewsLock issues its SET LOCAL timeouts and the advisory lock + // through execute; the plain-transaction path never calls it. + expect(dbChainMockFns.execute).toHaveBeenCalled() + }) + + it('a rename stays a plain transaction, off the lock', async () => { + queueTableRows(tableViews, [{ id: 'view-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, name: 'Renamed' }]) + + await updateTableView({ viewId: 'view-1', tableId: 'table-1', name: 'Renamed', columns }) + + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 5c5b5c2bc89..c39b81c9ae9 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -432,7 +432,11 @@ async function withTableViewsLock( export interface CreateTableViewData { tableId: string workspaceId: string - name: string + /** + * Omit for `View N`, numbered after the views the table has — decided under + * the views lock, so two unnamed creates can never pick the same N. + */ + name?: string config: TableViewConfig userId: string columns: ColumnDefinition[] @@ -472,7 +476,7 @@ export interface CreateTableViewData { * creating a view would fail for the duration of an unrelated long mutation. */ export async function createTableView(data: CreateTableViewData): Promise { - const name = normalizeName(data.name) + const explicitName = data.name === undefined ? undefined : normalizeName(data.name) const config = normalizeViewConfigForStorage( data.config, data.columns, @@ -516,7 +520,7 @@ export async function createTableView(data: CreateTableViewData): Promise { - const outcome = await db.transaction(async (tx) => { + const runWrite = (write: (trx: DbTransaction) => Promise): Promise => + data.isDefault === true ? withTableViewsLock(data.tableId, write) : db.transaction(write) + const outcome = await runWrite(async (tx) => { // Confirm the target exists BEFORE demoting. The demotion has to run first — // the partial unique index rejects a second default — but on a PATCH naming a // missing view the target update matches nothing, so without this the demote From 9a6b0ce64697ccd4c96dda6cc540acacfafaf55f Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:49:20 -0700 Subject: [PATCH 03/17] refactor(copilot): drop the direct view tools, pin views through table_views Views stay with the table subagent's multiplexed table_views; the orchestrator delegates as before. Its create/update/set-default results now name the table and view they wrote, and resource extraction turns that into the pinned table resource, so the panel opens (or switches) the table on that view. Unknown column names are classified as validation errors, and create_view's isDefault lands in the same locked transaction as the insert. The stream/persistence plumbing for viewId, the pin store, and the lock on default promotion are unchanged. --- .../home/components/message-content/utils.ts | 2 - .../home/hooks/stream/stream-helpers.ts | 1 - .../lib/copilot/generated/tool-catalog-v1.ts | 158 +--------------- .../lib/copilot/generated/tool-schemas-v1.ts | 165 +--------------- .../lib/copilot/resources/extraction.test.ts | 72 ++++--- apps/sim/lib/copilot/resources/extraction.ts | 21 ++- apps/sim/lib/copilot/tools/server/router.ts | 9 - .../server/table/create-table-view.test.ts | 162 ---------------- .../tools/server/table/create-table-view.ts | 63 ------- .../server/table/edit-table-view.test.ts | 178 ------------------ .../tools/server/table/edit-table-view.ts | 83 -------- .../tools/server/table/table-views.test.ts | 59 +++++- .../copilot/tools/server/table/table-views.ts | 107 ++++++++--- .../tools/server/table/view-tool-shared.ts | 86 --------- .../lib/copilot/tools/tool-display.test.ts | 15 -- apps/sim/lib/copilot/tools/tool-display.ts | 19 -- apps/sim/lib/copilot/vfs/serializers.ts | 2 +- apps/sim/lib/table/application/views.ts | 37 +--- apps/sim/lib/table/views/service.test.ts | 33 ---- apps/sim/lib/table/views/service.ts | 28 +-- 20 files changed, 201 insertions(+), 1099 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/create-table-view.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/edit-table-view.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/edit-table-view.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/view-tool-shared.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index e4e84f176b7..64176c822c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -58,8 +58,6 @@ const TOOL_ICONS: Record = { search_knowledge_base: Database, table: TableIcon, query_user_table: TableIcon, - create_table_view: TableIcon, - edit_table_view: TableIcon, job: Calendar, agent: AgentIcon, custom_tool: Wrench, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 053ca1918bb..afeb703e08d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -221,7 +221,6 @@ export function resolveIntegrationToolDisplayTitle(tool: { * client resolves the id against the workflow registry. */ const TABLE_SCOPED_TOOL_IDS = new Set([ - 'create_table_view', 'table_automations', 'table_columns', 'table_enrichments', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 6db4d64d7fb..4bf4b746449 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -37,7 +37,6 @@ export interface ToolCatalogEntry { | 'connect_slack_bot' | 'cp' | 'create_empty_file' - | 'create_table_view' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_workspace_mcp_server' @@ -47,7 +46,6 @@ export interface ToolCatalogEntry { | 'deploy_as_mcp' | 'diff_workflows' | 'download_file' - | 'edit_table_view' | 'edit_workflow' | 'extensions' | 'extract_doc_assets' @@ -168,7 +166,6 @@ export interface ToolCatalogEntry { | 'connect_slack_bot' | 'cp' | 'create_empty_file' - | 'create_table_view' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_workspace_mcp_server' @@ -178,7 +175,6 @@ export interface ToolCatalogEntry { | 'deploy_as_mcp' | 'diff_workflows' | 'download_file' - | 'edit_table_view' | 'edit_workflow' | 'extensions' | 'extract_doc_assets' @@ -1745,82 +1741,6 @@ export const CreateEmptyFile: ToolCatalogEntry = { capabilities: ['file_output'], } -export const CreateTableView: ToolCatalogEntry = { - id: 'create_table_view', - name: 'create_table_view', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - config: { - type: 'object', - description: - "Saved configuration, in the same shape as an entry of the table's views.json. Omit for an unfiltered view that shows every row and column.", - properties: { - filter: { - type: ['object', 'null'], - description: - 'Row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null to show every row.', - }, - hiddenColumns: { - type: 'array', - description: - 'Column names hidden in the UI while this view is active. Display-only — queries through the view still return every column.', - items: { type: 'string' }, - }, - sort: { - type: ['array', 'null'], - description: - 'Ordered sort spec, e.g. [{"field":"due","direction":"asc"}], with column NAMES. Omit or null for the table\'s natural order.', - items: { - type: 'object', - properties: { - direction: { - type: 'string', - description: 'Sort direction for this column.', - enum: ['asc', 'desc'], - }, - field: { type: 'string', description: 'Exact column name to sort by.' }, - }, - required: ['field', 'direction'], - }, - }, - }, - }, - isDefault: { - type: 'boolean', - description: - "Make this view the table's default: the view the table opens on when nobody has picked one. At most one per table; setting it clears the previous default.", - }, - name: { - type: 'string', - description: - 'Display name for the view, e.g. "Overdue". Defaults to "View N" when omitted. References always use the view id, so the name is purely display.', - }, - tableId: { type: 'string', description: "Table ID (tbl_...) from the table's meta.json." }, - }, - required: ['tableId'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - '{ viewId, tableId, tableName, view } — view is the created view in the same shape as a views.json entry.', - }, - message: { - type: 'string', - description: 'Human-readable outcome summary, including the new view id.', - }, - success: { type: 'boolean', description: 'Whether the view was created.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - export const CreateWorkflow: ToolCatalogEntry = { id: 'create_workflow', name: 'create_workflow', @@ -2316,78 +2236,6 @@ export const DownloadFile: ToolCatalogEntry = { capabilities: ['file_output'], } -export const EditTableView: ToolCatalogEntry = { - id: 'edit_table_view', - name: 'edit_table_view', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - config: { - type: 'object', - description: - "Configuration parts to replace, in the same shape as an entry of the table's views.json. Each part you include replaces the saved one; omitted parts are kept.", - properties: { - filter: { - type: ['object', 'null'], - description: - 'Row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit to keep the saved filter; null to clear it.', - }, - hiddenColumns: { - type: 'array', - description: - 'Column names hidden in the UI while this view is active; replaces the saved list (pass [] to unhide everything). Display-only — queries through the view still return every column.', - items: { type: 'string' }, - }, - sort: { - type: ['array', 'null'], - description: - 'Ordered sort spec, e.g. [{"field":"due","direction":"asc"}], with column NAMES. Omit to keep the saved sort; null to clear it.', - items: { - type: 'object', - properties: { - direction: { - type: 'string', - description: 'Sort direction for this column.', - enum: ['asc', 'desc'], - }, - field: { type: 'string', description: 'Exact column name to sort by.' }, - }, - required: ['field', 'direction'], - }, - }, - }, - }, - isDefault: { - type: 'boolean', - description: - "true makes this view the table's default (clearing the previous default); false demotes it. Omit to leave the flag as it is.", - }, - name: { - type: 'string', - description: 'New display name for the view. Omit to keep the current name.', - }, - viewId: { type: 'string', description: "View ID from the table's views.json." }, - }, - required: ['viewId'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - '{ viewId, tableId, tableName, view } — view is the updated view in the same shape as a views.json entry.', - }, - message: { type: 'string', description: 'Human-readable outcome summary.' }, - success: { type: 'boolean', description: 'Whether the view was updated.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - export const EditWorkflow: ToolCatalogEntry = { id: 'edit_workflow', name: 'edit_workflow', @@ -5938,7 +5786,7 @@ export const TableViews: ToolCatalogEntry = { description: 'Arguments for the operation', properties: { filter: { - type: 'object', + type: ['object', 'null'], description: 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null for an unfiltered view.', }, @@ -5959,7 +5807,7 @@ export const TableViews: ToolCatalogEntry = { 'View display name (required for create_view; optional rename on update_view). Free-form label; references always use the view ID, so names are purely display.', }, sort: { - type: 'array', + type: ['array', 'null'], description: 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. Omit or null for default ordering.', }, @@ -7179,7 +7027,6 @@ export const TOOL_CATALOG: Record = { [ConnectSlackBot.id]: ConnectSlackBot, [Cp.id]: Cp, [CreateEmptyFile.id]: CreateEmptyFile, - [CreateTableView.id]: CreateTableView, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, @@ -7189,7 +7036,6 @@ export const TOOL_CATALOG: Record = { [DeployAsMcp.id]: DeployAsMcp, [DiffWorkflows.id]: DiffWorkflows, [DownloadFile.id]: DownloadFile, - [EditTableView.id]: EditTableView, [EditWorkflow.id]: EditWorkflow, [Extensions.id]: Extensions, [ExtractDocAssets.id]: ExtractDocAssets, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 79b897e770d..85d1f050053 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1681,87 +1681,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_table_view: { - parameters: { - type: 'object', - properties: { - config: { - type: 'object', - description: - "Saved configuration, in the same shape as an entry of the table's views.json. Omit for an unfiltered view that shows every row and column.", - properties: { - filter: { - type: ['object', 'null'], - description: - 'Row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null to show every row.', - }, - hiddenColumns: { - type: 'array', - description: - 'Column names hidden in the UI while this view is active. Display-only — queries through the view still return every column.', - items: { - type: 'string', - }, - }, - sort: { - type: ['array', 'null'], - description: - 'Ordered sort spec, e.g. [{"field":"due","direction":"asc"}], with column NAMES. Omit or null for the table\'s natural order.', - items: { - type: 'object', - properties: { - direction: { - type: 'string', - description: 'Sort direction for this column.', - enum: ['asc', 'desc'], - }, - field: { - type: 'string', - description: 'Exact column name to sort by.', - }, - }, - required: ['field', 'direction'], - }, - }, - }, - }, - isDefault: { - type: 'boolean', - description: - "Make this view the table's default: the view the table opens on when nobody has picked one. At most one per table; setting it clears the previous default.", - }, - name: { - type: 'string', - description: - 'Display name for the view, e.g. "Overdue". Defaults to "View N" when omitted. References always use the view id, so the name is purely display.', - }, - tableId: { - type: 'string', - description: "Table ID (tbl_...) from the table's meta.json.", - }, - }, - required: ['tableId'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - '{ viewId, tableId, tableName, view } — view is the created view in the same shape as a views.json entry.', - }, - message: { - type: 'string', - description: 'Human-readable outcome summary, including the new view id.', - }, - success: { - type: 'boolean', - description: 'Whether the view was created.', - }, - }, - required: ['success', 'message'], - }, - }, create_workflow: { parameters: { type: 'object', @@ -2282,86 +2201,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_table_view: { - parameters: { - type: 'object', - properties: { - config: { - type: 'object', - description: - "Configuration parts to replace, in the same shape as an entry of the table's views.json. Each part you include replaces the saved one; omitted parts are kept.", - properties: { - filter: { - type: ['object', 'null'], - description: - 'Row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit to keep the saved filter; null to clear it.', - }, - hiddenColumns: { - type: 'array', - description: - 'Column names hidden in the UI while this view is active; replaces the saved list (pass [] to unhide everything). Display-only — queries through the view still return every column.', - items: { - type: 'string', - }, - }, - sort: { - type: ['array', 'null'], - description: - 'Ordered sort spec, e.g. [{"field":"due","direction":"asc"}], with column NAMES. Omit to keep the saved sort; null to clear it.', - items: { - type: 'object', - properties: { - direction: { - type: 'string', - description: 'Sort direction for this column.', - enum: ['asc', 'desc'], - }, - field: { - type: 'string', - description: 'Exact column name to sort by.', - }, - }, - required: ['field', 'direction'], - }, - }, - }, - }, - isDefault: { - type: 'boolean', - description: - "true makes this view the table's default (clearing the previous default); false demotes it. Omit to leave the flag as it is.", - }, - name: { - type: 'string', - description: 'New display name for the view. Omit to keep the current name.', - }, - viewId: { - type: 'string', - description: "View ID from the table's views.json.", - }, - }, - required: ['viewId'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - '{ viewId, tableId, tableName, view } — view is the updated view in the same shape as a views.json entry.', - }, - message: { - type: 'string', - description: 'Human-readable outcome summary.', - }, - success: { - type: 'boolean', - description: 'Whether the view was updated.', - }, - }, - required: ['success', 'message'], - }, - }, edit_workflow: { parameters: { type: 'object', @@ -5879,7 +5718,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Arguments for the operation', properties: { filter: { - type: 'object', + type: ['object', 'null'], description: 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null for an unfiltered view.', }, @@ -5902,7 +5741,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'View display name (required for create_view; optional rename on update_view). Free-form label; references always use the view ID, so names are purely display.', }, sort: { - type: 'array', + type: ['array', 'null'], description: 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. Omit or null for default ordering.', }, diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index e1e51054ecd..49e70a29750 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -195,38 +195,62 @@ describe('extractDeletedResourcesFromToolResult', () => { }) }) -describe('extractResourcesFromToolResult for the view tools', () => { - it.each(['create_table_view', 'edit_table_view'])( - '%s opens the table pinned to the view it touched', - (toolName) => { - const resources = extractResourcesFromToolResult( - toolName, - { tableId: 'tbl_1' }, +describe('extractResourcesFromToolResult for table_views', () => { + const written = { + success: true, + message: 'Created view "Overdue" (view_1)', + data: { + tableId: 'tbl_1', + tableName: 'Invoices', + viewId: 'view_1', + view: { id: 'view_1', name: 'Overdue', isDefault: false, filter: null, sort: null }, + }, + } + + it.each(['create_view', 'update_view', 'set_default_view'])( + '%s opens the table pinned to the view it wrote', + (operation) => { + expect( + extractResourcesFromToolResult( + 'table_views', + { operation, args: { tableId: 'tbl_1' } }, + written + ) + ).toEqual([{ type: 'table', id: 'tbl_1', title: 'Invoices', viewId: 'view_1' }]) + } + ) + + it('a delete opens the table without a pin', () => { + expect( + extractResourcesFromToolResult( + 'table_views', + { operation: 'delete_view', args: { tableId: 'tbl_1', viewId: 'view_1' } }, { success: true, - message: 'Created view "Overdue" (view_1) on table "Invoices"', - data: { - viewId: 'view_1', - tableId: 'tbl_1', - tableName: 'Invoices', - view: { id: 'view_1', name: 'Overdue', isDefault: false, filter: null, sort: null }, - }, + message: 'Deleted view "Overdue"', + data: { tableId: 'tbl_1', tableName: 'Invoices' }, } ) + ).toEqual([{ type: 'table', id: 'tbl_1', title: 'Invoices' }]) + }) - expect(resources).toEqual([ - { type: 'table', id: 'tbl_1', title: 'Invoices', viewId: 'view_1' }, - ]) - } - ) - - it('yields nothing for a failed view call, which names no table', () => { + it.each(['list_views', 'get_view'])('%s opens nothing', (operation) => { expect( extractResourcesFromToolResult( - 'edit_table_view', - { viewId: 'view_1' }, - { success: false, message: 'viewId is required' } + 'table_views', + { operation, args: { tableId: 'tbl_1' } }, + written ) ).toEqual([]) }) + + it('falls back to the argument table id when the result names none', () => { + expect( + extractResourcesFromToolResult( + 'table_views', + { operation: 'update_view', args: { tableId: 'tbl_1', viewId: 'view_1' } }, + { success: true, message: 'Updated view' } + ) + ).toEqual([{ type: 'table', id: 'tbl_1', title: 'Table' }]) + }) }) diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index acb032841d9..cc555b3c140 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,10 +1,8 @@ import { toRecord } from '@sim/utils/object' import { CreateEmptyFile, - CreateTableView, CreateWorkflow, DownloadFile, - EditTableView, EditWorkflow, Ffmpeg, GenerateAudio, @@ -15,6 +13,7 @@ import { PrepareFileEdit, Rm, RunFunction, + TableViews, UserTable, } from '@/lib/copilot/generated/tool-catalog-v1' import type { MothershipResource, MothershipResourceType } from './types' @@ -24,13 +23,12 @@ type ResourceType = MothershipResourceType const RESOURCE_TOOL_NAMES: Set = new Set([ UserTable.id, + TableViews.id, CreateEmptyFile.id, PrepareFileEdit.id, DownloadFile.id, CreateWorkflow.id, EditWorkflow.id, - CreateTableView.id, - EditTableView.id, RunFunction.id, ManageKnowledgeBase.id, Knowledge.id, @@ -56,6 +54,7 @@ function getWorkspaceFileTarget( } const READ_ONLY_TABLE_OPS = new Set(['get', 'get_schema', 'get_row', 'query_rows']) +const READ_ONLY_VIEW_OPS = new Set(['list_views', 'get_view']) const READ_ONLY_KB_OPS = new Set(['get', 'query', 'list_tags', 'get_tag_usage']) const READ_ONLY_KNOWLEDGE_ACTIONS = new Set(['listed', 'queried']) @@ -200,12 +199,14 @@ export function extractResourcesFromToolResult( return [] } - // The view tools name their table AND the view they touched, so the panel - // opens the table pinned to that view rather than its default. - case CreateTableView.id: - case EditTableView.id: { - const tableId = data.tableId - if (typeof tableId !== 'string' || !tableId) return [] + // The table agent's view tool. A write names the table it touched and — for + // create/update/set-default — the view, so the panel opens the table pinned + // to that view; a delete opens the table unpinned. Reads open nothing. + case TableViews.id: { + if (READ_ONLY_VIEW_OPS.has(getOperation(params) ?? '')) return [] + const args = toRecord(params?.args) + const tableId = (data.tableId as string) ?? (args.tableId as string) + if (!tableId) return [] const viewId = data.viewId return [ { diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index cbeb10ffab3..025a1195123 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -4,9 +4,7 @@ import { z } from 'zod' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { CreateEmptyFile, - CreateTableView, DownloadFile, - EditTableView, Ffmpeg, GenerateAudio, GenerateImage, @@ -51,8 +49,6 @@ import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' -import { createTableViewServerTool } from '@/lib/copilot/tools/server/table/create-table-view' -import { editTableViewServerTool } from '@/lib/copilot/tools/server/table/edit-table-view' import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' @@ -158,9 +154,6 @@ const WRITE_ACTIONS: Record = { [GenerateVideo.id]: ['generate'], [GenerateAudio.id]: ['generate'], [Ffmpeg.id]: ['*'], - // Saved-view create/edit are writes on the table regardless of arguments. - [CreateTableView.id]: ['*'], - [EditTableView.id]: ['*'], // Paid external-provider lookups (hosted-key cost), like the media tools. [enrichmentRunServerTool.name]: ['*'], } @@ -194,8 +187,6 @@ const baseServerToolRegistry: Record = { [tableAutomationsServerTool.name]: tableAutomationsServerTool, [tableEnrichmentsServerTool.name]: tableEnrichmentsServerTool, [tableViewsServerTool.name]: tableViewsServerTool, - [createTableViewServerTool.name]: createTableViewServerTool, - [editTableViewServerTool.name]: editTableViewServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts b/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts deleted file mode 100644 index 04d7427a37b..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/create-table-view.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const useCases = vi.hoisted(() => ({ - list: vi.fn(), - create: vi.fn(), -})) - -vi.mock('@/lib/table/application/views', () => ({ - listTableViewsUseCase: { operation: { id: 'tables.views.list' }, execute: useCases.list }, - createTableViewUseCase: { operation: { id: 'tables.views.create' }, execute: useCases.create }, -})) - -const executeUseCase = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ - executeCopilotTableUseCase: executeUseCase, -})) - -import { createTableViewServerTool } from '@/lib/copilot/tools/server/table/create-table-view' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { createTableViewUseCase, listTableViewsUseCase } from '@/lib/table/application/views' - -const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never - -const columns = [ - { id: 'col_a', name: 'status', type: 'string' }, - { id: 'col_b', name: 'due', type: 'date' }, -] -const table = { id: 'tbl-1', name: 'Invoices', schema: { columns } } - -describe('create_table_view', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('creates a name-translated view in one call and names the table for the panel', async () => { - executeUseCase - .mockResolvedValueOnce({ table, views: [{ id: 'view-0' }] }) - .mockResolvedValueOnce({ - table, - view: { - id: 'view-1', - name: 'Overdue', - isDefault: false, - config: { - filter: { all: [{ field: 'col_a', op: 'ne', value: 'Done' }] }, - sort: [{ field: 'col_b', direction: 'asc' }], - }, - }, - }) - - const result = await createTableViewServerTool.execute( - { - tableId: 'tbl-1', - name: 'Overdue', - config: { - filter: { all: [{ field: 'status', op: 'ne', value: 'Done' }] }, - sort: [{ field: 'due', direction: 'asc' }], - }, - }, - context - ) - - expect(executeUseCase).toHaveBeenNthCalledWith( - 1, - context, - listTableViewsUseCase, - { tableId: 'tbl-1', workspaceId: 'ws-1' }, - { tableId: 'tbl-1' } - ) - expect(executeUseCase).toHaveBeenNthCalledWith( - 2, - context, - createTableViewUseCase, - { - tableId: 'tbl-1', - workspaceId: 'ws-1', - name: 'Overdue', - config: { - filter: { all: [{ field: 'col_a', op: 'ne', value: 'Done' }] }, - sort: [{ field: 'col_b', direction: 'asc' }], - }, - isDefault: undefined, - }, - { tableId: 'tbl-1' } - ) - expect(result.success).toBe(true) - expect(result.message).toContain('view-1') - expect(result.data).toEqual({ - viewId: 'view-1', - tableId: 'tbl-1', - tableName: 'Invoices', - view: { - id: 'view-1', - name: 'Overdue', - isDefault: false, - filter: { all: [{ field: 'status', op: 'ne', value: 'Done' }] }, - sort: [{ field: 'due', direction: 'asc' }], - hiddenColumns: undefined, - }, - }) - }) - - it('leaves an omitted name to the service (numbered under the lock) and passes isDefault through', async () => { - executeUseCase - .mockResolvedValueOnce({ table, views: [{ id: 'view-0' }, { id: 'view-1' }] }) - .mockResolvedValueOnce({ - table, - view: { id: 'view-2', name: 'View 3', isDefault: true, config: {} }, - }) - - const result = await createTableViewServerTool.execute( - { tableId: 'tbl-1', name: ' ', isDefault: true }, - context - ) - - expect(executeUseCase).toHaveBeenNthCalledWith( - 2, - context, - createTableViewUseCase, - { tableId: 'tbl-1', workspaceId: 'ws-1', name: undefined, config: {}, isDefault: true }, - { tableId: 'tbl-1' } - ) - expect(result.success).toBe(true) - expect(result.message).toContain('"View 3"') - expect(result.message).toContain('as its default') - expect(result.data?.view.isDefault).toBe(true) - }) - - it("classifies an unknown column as the caller's mistake, before any write", async () => { - executeUseCase.mockResolvedValueOnce({ table, views: [] }) - - const failure = await createTableViewServerTool - .execute( - { - tableId: 'tbl-1', - name: 'Urgent', - config: { filter: { all: [{ field: 'priority', op: 'eq', value: 'high' }] } }, - }, - context - ) - .catch((error: unknown) => error) - - expect(asOrchestrationError(failure)?.code).toBe('validation') - expect(asOrchestrationError(failure)?.message).toMatch(/Unknown column\(s\): priority/) - expect(executeUseCase).toHaveBeenCalledTimes(1) - }) - - it('refuses without a table id and without workspace context', async () => { - expect(await createTableViewServerTool.execute({ tableId: ' ' }, context)).toEqual({ - success: false, - message: 'tableId is required', - }) - expect( - await createTableViewServerTool.execute({ tableId: 'tbl-1' }, { userId: 'user-1' } as never) - ).toEqual({ success: false, message: 'Workspace ID is required' }) - expect(executeUseCase).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/table/create-table-view.ts b/apps/sim/lib/copilot/tools/server/table/create-table-view.ts deleted file mode 100644 index 9096fa21511..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/create-table-view.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' -import { CreateTableView } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { - presentTableView, - type TableViewToolConfig, - type TableViewToolResult, - viewToolConfigToPatch, -} from '@/lib/copilot/tools/server/table/view-tool-shared' -import type { TableSchema } from '@/lib/table' -import { createTableViewUseCase, listTableViewsUseCase } from '@/lib/table/application/views' - -interface CreateTableViewArgs { - tableId?: string - name?: string - config?: TableViewToolConfig - isDefault?: boolean -} - -/** - * The main agent's direct path to a new saved view (the table subagent goes - * through table_views). One list read supplies the columns for name→id - * translation; the create then lands in a single locked transaction — default - * flag and, when no name was given, the `View N` fallback included, so two - * unnamed creates can never pick the same N. The result names the table so - * resource extraction opens the panel pinned to the new view. - */ -export const createTableViewServerTool: BaseServerTool = { - name: CreateTableView.id, - async execute(params, context) { - const tableId = params?.tableId?.trim() - const workspaceId = context?.workspaceId - if (!tableId) return { success: false, message: 'tableId is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - - const listed = await executeCopilotTableUseCase( - context, - listTableViewsUseCase, - { tableId, workspaceId }, - { tableId } - ) - const columns = (listed.table.schema as TableSchema).columns - const name = params.name?.trim() || undefined - const created = await executeCopilotTableUseCase( - context, - createTableViewUseCase, - { - tableId, - workspaceId, - name, - config: viewToolConfigToPatch(params.config ?? {}, columns), - isDefault: params.isDefault, - }, - { tableId } - ) - const view = presentTableView(created.view, columns) - return { - success: true, - message: `Created view "${view.name}" (${view.id}) on table "${created.table.name}"${view.isDefault ? ' as its default' : ''}`, - data: { viewId: view.id, tableId: created.table.id, tableName: created.table.name, view }, - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/table/edit-table-view.test.ts b/apps/sim/lib/copilot/tools/server/table/edit-table-view.test.ts deleted file mode 100644 index 022c568790d..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/edit-table-view.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const useCases = vi.hoisted(() => ({ - owner: vi.fn(), - read: vi.fn(), - update: vi.fn(), -})) - -vi.mock('@/lib/table/application/views', () => ({ - resolveTableViewOwnerUseCase: { - operation: { id: 'tables.views.read' }, - execute: useCases.owner, - }, - readTableViewUseCase: { operation: { id: 'tables.views.read' }, execute: useCases.read }, - updateTableViewUseCase: { operation: { id: 'tables.views.update' }, execute: useCases.update }, -})) - -const executeUseCase = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ - executeCopilotTableUseCase: executeUseCase, -})) - -import { editTableViewServerTool } from '@/lib/copilot/tools/server/table/edit-table-view' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { - readTableViewUseCase, - resolveTableViewOwnerUseCase, - updateTableViewUseCase, -} from '@/lib/table/application/views' - -const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never - -const columns = [ - { id: 'col_a', name: 'status', type: 'string' }, - { id: 'col_b', name: 'due', type: 'date' }, -] -const table = { id: 'tbl-1', name: 'Invoices', schema: { columns } } -const storedView = { - id: 'view-1', - name: 'Overdue', - isDefault: false, - config: { sort: [{ field: 'col_b', direction: 'asc' }] }, -} - -/** owner lookup (workspace scope) → read (table scope) → update (table scope) */ -function queueHappyPath(updatedView: typeof storedView) { - executeUseCase - .mockResolvedValueOnce({ tableId: 'tbl-1' }) - .mockResolvedValueOnce({ table, view: storedView, columns }) - .mockResolvedValueOnce({ table, view: updatedView }) -} - -describe('edit_table_view', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('resolves the table from the view id without a scope, then re-enters table-scoped', async () => { - queueHappyPath({ - ...storedView, - config: { - filter: { all: [{ field: 'col_a', op: 'eq', value: 'Open' }] }, - sort: [{ field: 'col_b', direction: 'asc' }], - }, - }) - - const result = await editTableViewServerTool.execute( - { - viewId: 'view-1', - config: { filter: { all: [{ field: 'status', op: 'eq', value: 'Open' }] } }, - }, - context - ) - - // The delegated principal has no table to scope to yet, so the owner - // lookup must not claim one. - expect(executeUseCase).toHaveBeenNthCalledWith(1, context, resolveTableViewOwnerUseCase, { - viewId: 'view-1', - workspaceId: 'ws-1', - }) - expect(executeUseCase).toHaveBeenNthCalledWith( - 2, - context, - readTableViewUseCase, - { tableId: 'tbl-1', workspaceId: 'ws-1', viewId: 'view-1' }, - { tableId: 'tbl-1' } - ) - // No `sort` key at all: the patch is shallow-merged server-side, so a - // present-but-null sort would wipe the saved one. - expect(executeUseCase).toHaveBeenNthCalledWith( - 3, - context, - updateTableViewUseCase, - { - tableId: 'tbl-1', - workspaceId: 'ws-1', - viewId: 'view-1', - name: undefined, - configPatch: { filter: { all: [{ field: 'col_a', op: 'eq', value: 'Open' }] } }, - isDefault: undefined, - }, - { tableId: 'tbl-1' } - ) - expect(result.success).toBe(true) - expect(result.data).toEqual({ - viewId: 'view-1', - tableId: 'tbl-1', - tableName: 'Invoices', - view: { - id: 'view-1', - name: 'Overdue', - isDefault: false, - filter: { all: [{ field: 'status', op: 'eq', value: 'Open' }] }, - sort: [{ field: 'due', direction: 'asc' }], - hiddenColumns: undefined, - }, - }) - }) - - it('renames or promotes without touching the config', async () => { - queueHappyPath({ ...storedView, name: 'Late', isDefault: true }) - - const result = await editTableViewServerTool.execute( - { viewId: 'view-1', name: 'Late', isDefault: true, config: {} }, - context - ) - - const updateInput = executeUseCase.mock.calls[2][2] - expect(updateInput).toEqual({ - tableId: 'tbl-1', - workspaceId: 'ws-1', - viewId: 'view-1', - name: 'Late', - isDefault: true, - }) - expect(updateInput).not.toHaveProperty('configPatch') - expect(result.message).toBe('Updated view "Late" on table "Invoices"') - }) - - it("classifies an unknown column as the caller's mistake, before the write", async () => { - executeUseCase - .mockResolvedValueOnce({ tableId: 'tbl-1' }) - .mockResolvedValueOnce({ table, view: storedView, columns }) - - const failure = await editTableViewServerTool - .execute({ viewId: 'view-1', config: { hiddenColumns: ['priority'] } }, context) - .catch((error: unknown) => error) - - expect(asOrchestrationError(failure)?.code).toBe('validation') - expect(asOrchestrationError(failure)?.message).toMatch(/Unknown column\(s\): priority/) - expect(executeUseCase).toHaveBeenCalledTimes(2) - }) - - it('refuses a call that names nothing to change, before any lookup', async () => { - const result = await editTableViewServerTool.execute({ viewId: 'view-1', config: {} }, context) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/Nothing to change/) - expect(executeUseCase).not.toHaveBeenCalled() - }) - - it('refuses without a view id and without workspace context', async () => { - expect(await editTableViewServerTool.execute({ viewId: '', name: 'x' }, context)).toEqual({ - success: false, - message: 'viewId is required', - }) - expect( - await editTableViewServerTool.execute({ viewId: 'view-1', name: 'x' }, { - userId: 'user-1', - } as never) - ).toEqual({ success: false, message: 'Workspace ID is required' }) - expect(executeUseCase).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts b/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts deleted file mode 100644 index 3a82277270b..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/edit-table-view.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' -import { EditTableView } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { - hasViewConfigParts, - presentTableView, - type TableViewToolConfig, - type TableViewToolResult, - viewToolConfigToPatch, -} from '@/lib/copilot/tools/server/table/view-tool-shared' -import type { TableSchema } from '@/lib/table' -import { - readTableViewUseCase, - resolveTableViewOwnerUseCase, - updateTableViewUseCase, -} from '@/lib/table/application/views' - -interface EditTableViewArgs { - viewId?: string - name?: string - config?: TableViewToolConfig - isDefault?: boolean -} - -/** - * The main agent's direct path to changing a saved view by view id alone. - * Three authorized steps: a workspace-scoped lookup names the owning table - * (the delegated principal has no table scope to offer before that), then the - * table-scoped read supplies the columns the config patch is translated with, - * and the update runs as the ordinary table-scoped mutation. Config parts are - * replace-or-keep, so a filter change never clears the saved sort. - */ -export const editTableViewServerTool: BaseServerTool = { - name: EditTableView.id, - async execute(params, context) { - const viewId = params?.viewId?.trim() - const workspaceId = context?.workspaceId - if (!viewId) return { success: false, message: 'viewId is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - - const name = typeof params.name === 'string' ? params.name : undefined - const config = - params.config !== undefined && hasViewConfigParts(params.config) ? params.config : undefined - if (name === undefined && config === undefined && params.isDefault === undefined) { - return { - success: false, - message: - 'Nothing to change — pass name, config (filter, sort, hiddenColumns), and/or isDefault', - } - } - - const { tableId } = await executeCopilotTableUseCase(context, resolveTableViewOwnerUseCase, { - viewId, - workspaceId, - }) - const resolved = await executeCopilotTableUseCase( - context, - readTableViewUseCase, - { tableId, workspaceId, viewId }, - { tableId } - ) - const columns = (resolved.table.schema as TableSchema).columns - const updated = await executeCopilotTableUseCase( - context, - updateTableViewUseCase, - { - tableId, - workspaceId, - viewId, - name, - ...(config ? { configPatch: viewToolConfigToPatch(config, columns) } : {}), - isDefault: params.isDefault, - }, - { tableId } - ) - const view = presentTableView(updated.view, columns) - return { - success: true, - message: `Updated view "${view.name}" on table "${updated.table.name}"`, - data: { viewId: view.id, tableId, tableName: updated.table.name, view }, - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/table/table-views.test.ts b/apps/sim/lib/copilot/tools/server/table/table-views.test.ts index 56d4df7e797..89579a4cc0d 100644 --- a/apps/sim/lib/copilot/tools/server/table/table-views.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/table-views.test.ts @@ -26,6 +26,7 @@ vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ })) import { tableViewsServerTool } from '@/lib/copilot/tools/server/table/table-views' +import { asOrchestrationError } from '@/lib/core/orchestration/types' const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never @@ -33,7 +34,7 @@ const columns = [ { id: 'col_a', name: 'status', type: 'string' }, { id: 'col_b', name: 'due', type: 'date' }, ] -const table = { id: 'tbl-1', schema: { columns } } +const table = { id: 'tbl-1', name: 'Invoices', schema: { columns } } describe('table_views adapter', () => { beforeEach(() => { @@ -91,13 +92,57 @@ describe('table_views adapter', () => { expect(createInput.config.filter).toEqual({ all: [{ field: 'col_a', op: 'eq', value: 'Open' }], }) + expect(createInput).not.toHaveProperty('isDefault') + // What resource extraction reads to open the panel on the new view. + expect(result.data).toMatchObject({ tableId: 'tbl-1', tableName: 'Invoices', viewId: 'view-2' }) + }) + + it('makes the view default inside the same create, with no follow-up write', async () => { + executeUseCase.mockResolvedValueOnce({ table, views: [] }).mockResolvedValueOnce({ + view: { id: 'view-2', name: 'Mine', isDefault: true, config: {} }, + table, + }) + + const result = await tableViewsServerTool.execute( + { operation: 'create_view', args: { tableId: 'tbl-1', name: 'Mine', isDefault: true } }, + context + ) + + expect(executeUseCase).toHaveBeenCalledTimes(2) + expect(executeUseCase.mock.calls[1][2]).toMatchObject({ isDefault: true }) + expect(result.message).toContain('as default') + expect(result.data.view.isDefault).toBe(true) + }) + + it('names the table and view on update, and only the table on delete', async () => { + const stored = { id: 'view-1', name: 'Overdue', isDefault: false, config: {} } + executeUseCase.mockResolvedValueOnce({ table, views: [stored] }).mockResolvedValueOnce({ + view: { ...stored, name: 'Late' }, + table, + }) + const updated = await tableViewsServerTool.execute( + { operation: 'update_view', args: { tableId: 'tbl-1', viewId: 'view-1', name: 'Late' } }, + context + ) + expect(updated.data).toMatchObject({ + tableId: 'tbl-1', + tableName: 'Invoices', + viewId: 'view-1', + }) + + executeUseCase.mockResolvedValueOnce({ viewId: 'view-1', viewName: 'Late', table }) + const deleted = await tableViewsServerTool.execute( + { operation: 'delete_view', args: { tableId: 'tbl-1', viewId: 'view-1' } }, + context + ) + expect(deleted.data).toEqual({ tableId: 'tbl-1', tableName: 'Invoices' }) }) it('rejects unknown column names with the columns spelled out', async () => { executeUseCase.mockResolvedValueOnce({ table, views: [] }) - await expect( - tableViewsServerTool.execute( + const failure = await tableViewsServerTool + .execute( { operation: 'create_view', args: { @@ -108,7 +153,13 @@ describe('table_views adapter', () => { }, context ) - ).rejects.toThrow(/Unknown column/) + .catch((error: unknown) => error) + + // Classified as the caller's mistake, so the model sees the column name + // instead of a masked system error. + expect(asOrchestrationError(failure)?.code).toBe('validation') + expect(asOrchestrationError(failure)?.message).toMatch(/Unknown column/) + expect(executeUseCase).toHaveBeenCalledTimes(1) }) it('rejects unsupported operations without invoking anything', async () => { diff --git a/apps/sim/lib/copilot/tools/server/table/table-views.ts b/apps/sim/lib/copilot/tools/server/table/table-views.ts index 023607b67ac..6872f55d4b3 100644 --- a/apps/sim/lib/copilot/tools/server/table/table-views.ts +++ b/apps/sim/lib/copilot/tools/server/table/table-views.ts @@ -1,12 +1,8 @@ import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' import { TableViews } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { - presentTableView, - type TableViewToolConfig, - viewToolConfigToPatch, -} from '@/lib/copilot/tools/server/table/view-tool-shared' -import type { TableSchema, TableViewConfig } from '@/lib/table' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { SortSpec, TablePredicateInput, TableSchema, TableViewConfig } from '@/lib/table' import { createTableViewUseCase, deleteTableViewUseCase, @@ -14,6 +10,11 @@ import { readTableViewUseCase, updateTableViewUseCase, } from '@/lib/table/application/views' +import { + TableViewValidationError, + viewConfigIdsToNames, + viewConfigNamesToIds, +} from '@/lib/table/views/service' type TableViewsArgs = { operation: string @@ -26,12 +27,16 @@ type TableViewsResult = { data?: any } +type StoredView = { id: string; name: string; isDefault: boolean; config: TableViewConfig } + /** * Saved-view slice of the split table surface. Unlike the other slices this is * NOT a user_table passthrough — it adapts the dedicated view use cases. * Agents speak column NAMES; stored configs are keyed by stable column id, so * inputs translate names→ids on the way in and every returned view translates - * ids→names on the way out. + * ids→names on the way out. Every write also names the table and the view it + * touched in `data`; resource extraction reads that to open the panel on the + * view that was just written. */ export const tableViewsServerTool: BaseServerTool = { name: TableViews.id, @@ -43,8 +48,51 @@ export const tableViewsServerTool: BaseServerTool - viewToolConfigToPatch(args as TableViewToolConfig, columns) + const presentView = (view: StoredView, columns: TableSchema['columns']) => { + const named = viewConfigIdsToNames(view.config, columns) + return { + id: view.id, + name: view.name, + isDefault: view.isDefault, + filter: named.filter ?? null, + sort: named.sort ?? null, + hiddenColumns: named.hiddenColumns?.length ? named.hiddenColumns : undefined, + } + } + + // What a write hands back: the view, plus the ids the resource panel opens on. + const presentWrite = ( + table: { id: string; name: string }, + view: StoredView, + columns: TableSchema['columns'] + ) => ({ + tableId: table.id, + tableName: table.name, + viewId: view.id, + view: presentView(view, columns), + }) + + // Build the patch from only the keys the caller actually sent: the update + // path shallow-merges this into the stored config, so including an absent + // part as `null` silently wiped a view's saved sort when only the filter + // changed (and vice versa) — the doc promises "omit to keep, null to clear". + // The name→id translation runs here, outside the use case that would + // classify a bad column name, so it is classified here: unclassified, the + // model gets a masked "system error" instead of the column it got wrong. + const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig => { + const patch: Record = {} + if (args.filter !== undefined) patch.filter = args.filter as TablePredicateInput | null + if (args.sort !== undefined) patch.sort = args.sort as SortSpec | null + if (args.hiddenColumns !== undefined) patch.hiddenColumns = args.hiddenColumns as string[] + try { + return viewConfigNamesToIds(patch as TableViewConfig, columns) + } catch (error) { + if (error instanceof TableViewValidationError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + } switch (operation) { case 'list_views': { @@ -55,7 +103,7 @@ export const tableViewsServerTool: BaseServerTool presentTableView(view, columns)) + const views = result.views.map((view) => presentView(view, columns)) return { success: true, message: `Table has ${views.length} view(s)`, @@ -74,7 +122,7 @@ export const tableViewsServerTool: BaseServerTool - -/** Result envelope shared by create_table_view and edit_table_view. */ -export interface TableViewToolResult { - success: boolean - message: string - data?: { - viewId: string - tableId: string - tableName: string - view: PresentedTableView - } -} - -/** Whether a config argument names at least one part to write. */ -export function hasViewConfigParts(config: TableViewToolConfig): boolean { - return ( - config.filter !== undefined || config.sort !== undefined || config.hiddenColumns !== undefined - ) -} - -/** - * Builds the stored (id-domain) config from only the keys the caller sent. The - * update path shallow-merges the result into the stored config, so an absent - * part must stay absent — sending it as `null` silently wiped a view's saved - * sort when only the filter changed (and vice versa); the docs promise "omit to - * keep". An unknown column name is rejected here, in the adapter — outside the - * use case that would classify it — so it is classified on the spot: unclassified, - * the model gets a masked "system error" instead of the column it got wrong. - */ -export function viewToolConfigToPatch( - config: TableViewToolConfig, - columns: TableSchema['columns'] -): TableViewConfig { - const patch: Record = {} - if (config.filter !== undefined) patch.filter = config.filter - if (config.sort !== undefined) patch.sort = config.sort - if (config.hiddenColumns !== undefined) patch.hiddenColumns = config.hiddenColumns - try { - return viewConfigNamesToIds(patch as TableViewConfig, columns) - } catch (error) { - if (error instanceof TableViewValidationError) { - throw new OrchestrationError('validation', error.message) - } - throw error - } -} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index a50d5c61eda..253dd9d94d5 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -722,21 +722,6 @@ describe('resource-naming titles', () => { expect(getToolDisplayTitle('table_rows', { operation: 'update' })).toBe('Updating rows') }) - it('names the view the direct view tools create or edit', () => { - expect( - getToolDisplayTitle('create_table_view', { - tableId: 'tbl_1', - name: 'Overdue', - tableName: 'Invoices', - }) - ).toBe('Creating view Overdue in Invoices') - expect(getToolDisplayTitle('create_table_view', { tableId: 'tbl_1' })).toBe('Creating view') - expect(getToolDisplayTitle('edit_table_view', { viewId: 'view_1', name: 'Late' })).toBe( - 'Editing view Late' - ) - expect(getToolDisplayTitle('edit_table_view', { viewId: 'view_1' })).toBe('Editing view') - }) - it('names the block behind a block-schema read', () => { expect(getToolDisplayTitle('read', { path: 'components/blocks/slack_v2.json' })).toBe( 'Loading Slack' diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index fbd3b10817c..4f4acff1215 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -128,20 +128,6 @@ function splitTableTitle(name: string, args: ToolArgs): string { } } -/** - * Titles for the direct view tools. create_table_view carries the table id, so - * enrichment can name the table; edit_table_view addresses the view alone. - */ -function tableViewToolTitle(name: string, args: ToolArgs): string { - const view = stringArg(args, 'name') - const suffix = view ? ` ${view}` : '' - if (name === 'create_table_view') { - const table = stringArg(args, 'tableName') - return `Creating view${suffix}${table ? ` in ${table}` : ''}` - } - return `Editing view${suffix}` -} - function deploymentTitle(args: ToolArgs, deploymentType: string): string { const verb = stringArg(args, 'action') === 'undeploy' ? 'Undeploying' : 'Deploying' const workflow = firstStringArg(args, 'workflowName', 'name', 'title') @@ -561,8 +547,6 @@ const TOOL_TITLES: Record = { table_automations: 'Wiring automation', table_enrichments: 'Configuring enrichment', table_views: 'Editing views', - create_table_view: 'Creating view', - edit_table_view: 'Editing view', prepare_file_edit: 'Editing file', apply_file_edit: 'Writing changes', create_workflow: 'Creating workflow', @@ -842,9 +826,6 @@ export function getToolDisplayTitle(name: string, args?: Record case 'table_enrichments': case 'table_views': return splitTableTitle(name, args) - case 'create_table_view': - case 'edit_table_view': - return tableViewToolTitle(name, args) case 'search_knowledge_base': return searchKnowledgeBaseTitle(args) case 'manage_sandbox': diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 82d9f245e09..212a8ad674d 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1367,7 +1367,7 @@ export function serializeTableViews( hiddenColumns: view.hiddenColumns?.length ? view.hiddenColumns : undefined, updatedAt: view.updatedAt instanceof Date ? view.updatedAt.toISOString() : view.updatedAt, })), - note: 'Query a view via query_user_table {operation: "query_rows", args: {tableId, view: ""}} — the saved filter ANDs with any extra filter you pass. Create or change a view with create_table_view / edit_table_view (main agent) or table_views (table agent).', + note: 'Query a view via query_user_table {operation: "query_rows", args: {tableId, view: ""}} — the saved filter ANDs with any extra filter you pass. Manage views via the table agent (table_views).', }, null, 2 diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts index 3ae23cd1ef3..17a3a095599 100644 --- a/apps/sim/lib/table/application/views.ts +++ b/apps/sim/lib/table/application/views.ts @@ -3,16 +3,12 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { TableSchema, TableViewConfig } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' -import { - resolveActiveTableContext, - resolveTableWorkspaceContext, -} from '@/lib/table/application/context' +import { resolveActiveTableContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' import { createTableView, deleteTableView, getTableView, - getTableViewTableId, listTableViews, TableViewValidationError, updateTableView, @@ -69,37 +65,8 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({ }, }) -export interface ResolveTableViewOwnerInput { - viewId: string - workspaceId: string -} - -/** - * Names the table a view belongs to, for a caller holding only a view id (the - * agent's edit_table_view). Authorized at workspace level on purpose: the - * context carries no tableId yet, so a delegated principal needs no table scope - * to ask, and the answer is only an id. The caller then re-enters the - * table-scoped use cases with that id — which is where the table itself, and - * the principal's scope for it, are authorized. - */ -export const resolveTableViewOwnerUseCase = defineAuthorizedTableUseCase({ - operation: tableOperations.readView, - resolveContext: ({ input }: { input: ResolveTableViewOwnerInput }) => - resolveTableWorkspaceContext(input.workspaceId), - async execute({ input, context }) { - const tableId = await getTableViewTableId(input.viewId, context.workspaceId) - if (!tableId) - throw new OrchestrationError( - 'not_found', - `View "${input.viewId}" not found in this workspace — view ids are listed in each table's views.json.` - ) - return { tableId } - }, -}) - export interface CreateTableViewInput extends TableViewInput { - /** Omit to number the view after the ones the table already has (`View N`). */ - name?: string + name: string config: TableViewConfig /** Make the new view the table's default, demoting the previous one in the same transaction. */ isDefault?: boolean diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 911a2b80ea2..822830b868a 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -18,7 +18,6 @@ import { createTableView, deleteTableView, getTableView, - getTableViewTableId, normalizeStoredViewConfig, pruneViewConfig, updateTableView, @@ -732,22 +731,6 @@ describe('view config column-reference normalization', () => { }) }) -describe('getTableViewTableId', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - it('names the table a view belongs to', async () => { - queueTableRows(tableViews, [{ tableId: 'table-1' }]) - expect(await getTableViewTableId('view-1', 'ws-1')).toBe('table-1') - }) - - it('reads a view outside the asserted workspace as missing', async () => { - expect(await getTableViewTableId('view-elsewhere', 'ws-1')).toBeNull() - }) -}) - describe('default-view writers share the views lock', () => { const columns: ColumnDefinition[] = [] const viewRow = { @@ -767,22 +750,6 @@ describe('default-view writers share the views lock', () => { resetDbChainMock() }) - it('numbers an unnamed view after the ones the table has, from the count read under the lock', async () => { - queueTableRows(tableViews, [{ total: 2 }]) - dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, name: 'View 3' }]) - - const view = await createTableView({ - tableId: 'table-1', - workspaceId: 'ws-1', - config: {}, - userId: 'user-1', - columns, - }) - - expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ name: 'View 3' })) - expect(view.name).toBe('View 3') - }) - it('promoting a view takes the per-table advisory lock the create path holds', async () => { queueTableRows(tableViews, [{ id: 'view-1' }]) dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, isDefault: true }]) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index c39b81c9ae9..7d2df73182b 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -385,24 +385,6 @@ export async function getTableView( return row ? toTableView(row, columns) : null } -/** - * The table a view belongs to, scoped to the workspace the caller asserted so a - * view id from another workspace reads as missing rather than naming its owner. - * Lets a caller holding only a view id (the agent's edit_table_view) reach the - * table-scoped use cases without a lookup surface of its own. - */ -export async function getTableViewTableId( - viewId: string, - workspaceId: string -): Promise { - const [row] = await db - .select({ tableId: tableViews.tableId }) - .from(tableViews) - .where(and(eq(tableViews.id, viewId), eq(tableViews.workspaceId, workspaceId))) - .limit(1) - return row?.tableId ?? null -} - function normalizeName(name: string): string { const trimmed = name.trim() if (!trimmed) throw new TableViewValidationError('View name cannot be empty') @@ -432,11 +414,7 @@ async function withTableViewsLock( export interface CreateTableViewData { tableId: string workspaceId: string - /** - * Omit for `View N`, numbered after the views the table has — decided under - * the views lock, so two unnamed creates can never pick the same N. - */ - name?: string + name: string config: TableViewConfig userId: string columns: ColumnDefinition[] @@ -476,7 +454,7 @@ export interface CreateTableViewData { * creating a view would fail for the duration of an unrelated long mutation. */ export async function createTableView(data: CreateTableViewData): Promise { - const explicitName = data.name === undefined ? undefined : normalizeName(data.name) + const name = normalizeName(data.name) const config = normalizeViewConfigForStorage( data.config, data.columns, @@ -520,7 +498,7 @@ export async function createTableView(data: CreateTableViewData): Promise Date: Fri, 28 Aug 2026 18:03:58 -0700 Subject: [PATCH 04/17] chore(copilot): sync table view update semantics --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 4 ++-- apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 4bf4b746449..ea8beb08ad6 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -5788,7 +5788,7 @@ export const TableViews: ToolCatalogEntry = { filter: { type: ['object', 'null'], description: - 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null for an unfiltered view.', + 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. On update_view, omit to keep the existing filter, pass null to clear it, or pass a predicate to replace it. On create_view, omit or pass null for an unfiltered view.', }, hiddenColumns: { type: 'array', @@ -5809,7 +5809,7 @@ export const TableViews: ToolCatalogEntry = { sort: { type: ['array', 'null'], description: - 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. Omit or null for default ordering.', + 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. On update_view, omit to keep the existing sort, pass null to clear it, or pass a sort spec to replace it. On create_view, omit or pass null for default ordering.', }, tableId: { type: 'string', description: 'Table ID (required for every operation)' }, viewId: { diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 85d1f050053..9e13d8e9bab 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -5720,7 +5720,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: ['object', 'null'], description: - 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null for an unfiltered view.', + 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. On update_view, omit to keep the existing filter, pass null to clear it, or pass a predicate to replace it. On create_view, omit or pass null for an unfiltered view.', }, hiddenColumns: { type: 'array', @@ -5743,7 +5743,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { sort: { type: ['array', 'null'], description: - 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. Omit or null for default ordering.', + 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. On update_view, omit to keep the existing sort, pass null to clear it, or pass a sort spec to replace it. On create_view, omit or pass null for default ordering.', }, tableId: { type: 'string', From 3aa7dbf1130553dba6e6d37b996bf8c206a9fd90 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:44:18 -0700 Subject: [PATCH 05/17] fix(copilot): sync table view sort item schema --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 8 ++++++++ apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 925ab01f430..33a29b3fe25 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -5831,6 +5831,14 @@ export const TableViews: ToolCatalogEntry = { type: ['array', 'null'], description: 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. On update_view, omit to keep the existing sort, pass null to clear it, or pass a sort spec to replace it. On create_view, omit or pass null for default ordering.', + items: { + type: 'object', + properties: { + direction: { type: 'string', enum: ['asc', 'desc'] }, + field: { type: 'string' }, + }, + required: ['field', 'direction'], + }, }, tableId: { type: 'string', description: 'Table ID (required for every operation)' }, viewId: { diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 21d284e7f7f..3e2e9030146 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -5754,6 +5754,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: ['array', 'null'], description: 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. On update_view, omit to keep the existing sort, pass null to clear it, or pass a sort spec to replace it. On create_view, omit or pass null for default ordering.', + items: { + type: 'object', + properties: { + direction: { + type: 'string', + enum: ['asc', 'desc'], + }, + field: { + type: 'string', + }, + }, + required: ['field', 'direction'], + }, }, tableId: { type: 'string', From 3e6eb75db8dbb588d63019137d35dc4cbdf5ffad Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:07:39 -0700 Subject: [PATCH 06/17] fix(copilot): persist table view pin updates --- .../app/api/copilot/chat/resources/route.ts | 11 ++- .../stream/handle-resource-event.test.ts | 43 +++++++++- .../hooks/stream/handle-resource-event.ts | 17 +++- .../home/hooks/stream/stream-context.ts | 3 +- .../[workspaceId]/home/hooks/use-chat.ts | 64 ++++++++++++--- apps/sim/lib/api/contracts/copilot.ts | 27 +++++- .../generated/mothership-stream-v1-schema.ts | 3 + .../copilot/generated/mothership-stream-v1.ts | 1 + .../copilot/request/session/contract.test.ts | 20 +++++ .../lib/copilot/request/session/contract.ts | 3 +- .../copilot/request/tools/resources.test.ts | 82 +++++++++++++++++++ .../lib/copilot/request/tools/resources.ts | 2 + .../lib/copilot/resources/extraction.test.ts | 4 +- apps/sim/lib/copilot/resources/extraction.ts | 8 +- apps/sim/lib/copilot/resources/persistence.ts | 9 +- apps/sim/lib/copilot/resources/types.test.ts | 31 +++++++ apps/sim/lib/copilot/resources/types.ts | 19 ++++- apps/sim/lib/table/views/service.test.ts | 5 +- apps/sim/lib/table/views/service.ts | 2 +- apps/sim/stores/table/view-pin/store.test.ts | 11 +++ apps/sim/stores/table/view-pin/store.ts | 8 ++ 21 files changed, 336 insertions(+), 37 deletions(-) create mode 100644 apps/sim/lib/copilot/request/tools/resources.test.ts diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 8e80e559a58..ae5186870d7 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -17,6 +17,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request/http' import type { ChatResource } from '@/lib/copilot/resources/persistence' +import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' import { canonicalizeDesktopSessionResource, mergeChatResource, @@ -43,8 +44,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } ) if (!parsed.success) return parsed.response - const { chatId, resource: requestedResource } = parsed.data.body + const { chatId, resource: requestedResource, clearViewId } = parsed.data.body const resource = canonicalizeDesktopSessionResource(requestedResource) + const resourceUpdate: MothershipResourceUpdate = + clearViewId === true ? { ...resource, clearViewId: true } : resource // Ephemeral UI tab (client does not POST this; guard for old clients / bugs). if (resource.id === 'streaming-file') { @@ -74,8 +77,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const prev = existing.find((r) => `${r.type}:${r.id}` === key) const merged: ChatResource[] = prev - ? existing.map((r) => (`${r.type}:${r.id}` === key ? mergeChatResource(r, resource) : r)) - : [...existing, resource] + ? existing.map((r) => + `${r.type}:${r.id}` === key ? mergeChatResource(r, resourceUpdate) : r + ) + : [...existing, mergeChatResource(undefined, resourceUpdate)] await db .update(copilotChats) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts index 368b098c1ff..3bd6a2e6f25 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts @@ -108,7 +108,11 @@ describe('handleResourceEvent removal', () => { }) }) -function tableUpsertEvent(id: string, viewId?: string): PersistedStreamEventEnvelope { +function tableUpsertEvent( + id: string, + viewId?: string, + clearViewId?: true +): PersistedStreamEventEnvelope { return { type: 'resource', v: 1, @@ -117,7 +121,13 @@ function tableUpsertEvent(id: string, viewId?: string): PersistedStreamEventEnve stream: { streamId: 's', cursor: '1' }, payload: { op: 'upsert', - resource: { type: 'table', id, title: 'Invoices', ...(viewId ? { viewId } : {}) }, + resource: { + type: 'table', + id, + title: 'Invoices', + ...(viewId ? { viewId } : {}), + ...(clearViewId ? { clearViewId } : {}), + }, }, } as PersistedStreamEventEnvelope } @@ -188,4 +198,33 @@ describe('handleResourceEvent saved-view pins', () => { expect(deps.setResources).not.toHaveBeenCalled() expect(useTableViewPinStore.getState().pins['tbl-1']).toBeUndefined() }) + + it('clears the stored and pending pin when the agent deletes a saved view', () => { + const open: MothershipResource = { + type: 'table', + id: 'tbl-1', + title: 'Invoices', + viewId: 'view-1', + } + useTableViewPinStore.getState().pin('tbl-1', 'view-1') + const deps = makeStreamLoopDeps({ + addResource: vi.fn(() => false), + resourcesRef: { current: [open] }, + }) + const ctx = { deps } as StreamLoopContext + + handleResourceEvent(ctx, tableUpsertEvent('tbl-1', undefined, true)) + + expect(deps.addResource).toHaveBeenCalledWith({ + type: 'table', + id: 'tbl-1', + title: 'Invoices', + clearViewId: true, + }) + const updater = (deps.setResources as ReturnType).mock.calls[0][0] as ( + current: MothershipResource[] + ) => MothershipResource[] + expect(updater([open])).toEqual([{ type: 'table', id: 'tbl-1', title: 'Invoices' }]) + expect(useTableViewPinStore.getState().pins['tbl-1']).toBeUndefined() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index 1e00012af78..e9565c7c5ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -45,9 +45,12 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven } = ctx.deps const onResourceEvent = onResourceEventRef.current const payload = parsed.payload + const shouldClearViewId = + payload.resource.type === 'table' && payload.resource.clearViewId === true // A saved view the agent just created or edited: the table opens on it, and // an already-open table switches to it. const pinnedViewId = + !shouldClearViewId && payload.resource.type === 'table' && typeof payload.resource.viewId === 'string' && payload.resource.viewId.trim() @@ -60,6 +63,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven typeof payload.resource.title === 'string' ? payload.resource.title : payload.resource.id, ...(pinnedViewId ? { viewId: pinnedViewId } : {}), }) + const resourceUpdate = shouldClearViewId ? { ...resource, clearViewId: true as const } : resource if (payload.op === MothershipStreamV1ResourceOp.remove) { const resourceType = resource.type @@ -109,7 +113,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven !shouldAutoActivatePreviewSession(previewForResource))) const wasAdded = shouldSuppressFileResourceActivation ? !resourcesRef.current.some((r) => r.type === resource.type && r.id === resource.id) - : addResource(resource) + : addResource(resourceUpdate) if (shouldSuppressFileResourceActivation && wasAdded) { setResources((current) => current.some((r) => r.type === resource.type && r.id === resource.id) @@ -136,6 +140,17 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven // Consumed by the embedded table once its views list carries the view — // which may be after the refetch below lands, or after the tab first opens. useTableViewPinStore.getState().pin(resource.id, pinnedViewId) + } else if (shouldClearViewId) { + setResources((current) => + current.some((r) => r.type === 'table' && r.id === resource.id && r.viewId !== undefined) + ? current.map((r) => { + if (r.type !== 'table' || r.id !== resource.id) return r + const { viewId: _viewId, ...unpinned } = r + return unpinned + }) + : current + ) + useTableViewPinStore.getState().clear(resource.id) } invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index 5d700ffae64..a10dbca49cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -5,6 +5,7 @@ import type { RevealedSimKeysByMessage } from '@/lib/copilot/chat/sim-key-redact import { captureRevealedSimKeys } from '@/lib/copilot/chat/sim-key-redaction' import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session' import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' +import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' import { createTurnModel, type TurnModel, @@ -95,7 +96,7 @@ export interface StreamLoopDeps { setResources: Dispatch> setActiveResourceId: Dispatch> - addResource: (resource: MothershipResource) => boolean + addResource: (resource: MothershipResourceUpdate) => boolean removeResource: (resourceType: MothershipResourceType, resourceId: string) => void startClientWorkflowTool: (id: string, name: string, args: Record) => void startClientLocalFilesystemTool: (id: string, name: string, args: Record) => void 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 5ff72cd06e8..49690c07f7c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -70,6 +70,8 @@ import { BROWSER_SESSION_RESOURCE_ID, isAddressableResource, isEphemeralResource, + type MothershipResourceUpdate, + mergeChatResource, sanitizeChatResources, TERMINAL_SESSION_RESOURCE_ID, } from '@/lib/copilot/resources/types' @@ -211,7 +213,7 @@ export interface UseChatReturn { resources: MothershipResource[] activeResourceId: string | null setActiveResourceId: (id: string | null) => void - addResource: (resource: MothershipResource) => boolean + addResource: (resource: MothershipResourceUpdate) => boolean removeResource: (resourceType: MothershipResourceType, resourceId: string) => void reorderResources: (resources: MothershipResource[]) => void messageQueue: QueuedMessage[] @@ -1383,6 +1385,7 @@ export function useChat( */ const undisplayableResourcesRef = useRef([]) const pendingPersistResourceKeysRef = useRef>(new Set()) + const pendingClearViewIdKeysRef = useRef>(new Set()) const inFlightResourceAddsRef = useRef>>(new Map()) const reorderNeededAfterFlushRef = useRef(false) @@ -1642,6 +1645,7 @@ export function useChat( useTableViewPinStore.getState().reset() undisplayableResourcesRef.current = [] pendingPersistResourceKeysRef.current.clear() + pendingClearViewIdKeysRef.current.clear() inFlightResourceAddsRef.current.clear() reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() @@ -1679,9 +1683,18 @@ export function useChat( const key = `${resource.type}:${resource.id}` if (!pendingKeys.has(key)) continue pendingKeys.delete(key) + const shouldClearViewId = pendingClearViewIdKeysRef.current.has(key) const promise = requestJson(addMothershipChatResourceContract, { - body: { chatId, resource }, + body: { + chatId, + resource, + ...(shouldClearViewId ? { clearViewId: true as const } : {}), + }, }) + .then((result) => { + if (shouldClearViewId) pendingClearViewIdKeysRef.current.delete(key) + return result + }) .catch((err) => { pendingPersistResourceKeysRef.current.add(key) logger.warn('Failed to flush pending resource; will retry on next hydration', err) @@ -1798,20 +1811,30 @@ export function useChat( const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current)) }, [chatHistory, pendingMessages]) - const addResource = useCallback((resource: MothershipResource): boolean => { + const addResource = useCallback((resourceUpdate: MothershipResourceUpdate): boolean => { // The single fan-in for tab creation, so the invariant lives here. - if (!isAddressableResource(resource)) { - logger.warn('Ignored a resource with no id', { type: resource.type, title: resource.title }) + if (!isAddressableResource(resourceUpdate)) { + logger.warn('Ignored a resource with no id', { + type: resourceUpdate.type, + title: resourceUpdate.title, + }) return false } - if (resourcesRef.current.some((r) => r.type === resource.type && r.id === resource.id)) { + const existing = resourcesRef.current.find( + (r) => r.type === resourceUpdate.type && r.id === resourceUpdate.id + ) + const resource = mergeChatResource(existing, resourceUpdate) + if (existing && resource === existing) { return false } setResources((prev) => { - const exists = prev.some((r) => r.type === resource.type && r.id === resource.id) - if (exists) return prev - return [...prev, resource] + const current = prev.find((r) => r.type === resource.type && r.id === resource.id) + if (!current) return [...prev, resource] + const merged = mergeChatResource(current, resourceUpdate) + return merged === current + ? prev + : prev.map((r) => (r.type === resource.type && r.id === resource.id ? merged : r)) }) // Synthetic result/preview panels are in-memory only. The browser tab // metadata is persisted even though its live page remains desktop-owned. @@ -1821,6 +1844,12 @@ export function useChat( const persistChatId = chatIdRef.current ?? selectedChatIdRef.current const key = `${resource.type}:${resource.id}` + const shouldClearViewId = resourceUpdate.clearViewId === true + if (shouldClearViewId) { + pendingClearViewIdKeysRef.current.add(key) + } else if (resourceUpdate.viewId !== undefined) { + pendingClearViewIdKeysRef.current.delete(key) + } // `resourcesRef` is written during render, so adds of the same resource in // one tick all read the pre-render list and all pass the check above. State // converges (the updater is idempotent) but each fired its own POST — 5-6 @@ -1828,12 +1857,21 @@ export function useChat( const alreadyPersisting = inFlightResourceAddsRef.current.has(key) || pendingPersistResourceKeysRef.current.has(key) if (alreadyPersisting) { - return true + pendingPersistResourceKeysRef.current.add(key) + return existing === undefined } if (persistChatId) { const promise = requestJson(addMothershipChatResourceContract, { - body: { chatId: persistChatId, resource }, + body: { + chatId: persistChatId, + resource, + ...(shouldClearViewId ? { clearViewId: true as const } : {}), + }, }) + .then((result) => { + if (shouldClearViewId) pendingClearViewIdKeysRef.current.delete(key) + return result + }) .catch((err) => { pendingPersistResourceKeysRef.current.add(key) logger.warn('Failed to persist resource; will retry on next hydration', err) @@ -1845,7 +1883,7 @@ export function useChat( } else { pendingPersistResourceKeysRef.current.add(key) } - return true + return existing === undefined }, []) const removeResource = useCallback((resourceType: MothershipResourceType, resourceId: string) => { @@ -1856,6 +1894,7 @@ export function useChat( if (isEphemeralResource({ type: resourceType, id: resourceId, title: '' })) return const key = `${resourceType}:${resourceId}` + pendingClearViewIdKeysRef.current.delete(key) const wasPending = pendingPersistResourceKeysRef.current.delete(key) const inFlightAdd = inFlightResourceAddsRef.current.get(key) if (wasPending && !inFlightAdd) return @@ -2344,6 +2383,7 @@ export function useChat( setActiveResourceId(null) useTableViewPinStore.getState().reset() pendingPersistResourceKeysRef.current.clear() + pendingClearViewIdKeysRef.current.clear() inFlightResourceAddsRef.current.clear() reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 0289ddc798f..e726c51bac7 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -100,10 +100,29 @@ const copilotChatResourceItemSchema = z.object({ viewId: z.string().min(1).optional(), }) -export const addCopilotChatResourceBodySchema = z.object({ - chatId: z.string(), - resource: copilotChatResourceItemSchema, -}) +export const addCopilotChatResourceBodySchema = z + .object({ + chatId: z.string(), + resource: copilotChatResourceItemSchema, + clearViewId: z.literal(true).optional(), + }) + .superRefine((body, ctx) => { + if (body.clearViewId !== true) return + if (body.resource.type !== 'table') { + ctx.addIssue({ + code: 'custom', + path: ['clearViewId'], + message: 'clearViewId is only valid for table resources', + }) + } + if (body.resource.viewId !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['resource', 'viewId'], + message: 'viewId must be omitted when clearViewId is true', + }) + } + }) export type AddCopilotChatResourceBody = z.input export const removeCopilotChatResourceBodySchema = z.object({ diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index cd4f1c943de..ecf8faf3f82 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -363,6 +363,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { MothershipStreamV1ResourceDescriptor: { additionalProperties: false, properties: { + clearViewId: { + type: 'boolean', + }, id: { type: 'string', }, diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index 848d92531dd..41a169a8648 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -279,6 +279,7 @@ export interface MothershipStreamV1ResourceUpsertPayload { resource: MothershipStreamV1ResourceDescriptor } export interface MothershipStreamV1ResourceDescriptor { + clearViewId?: boolean id: string title?: string type: string diff --git a/apps/sim/lib/copilot/request/session/contract.test.ts b/apps/sim/lib/copilot/request/session/contract.test.ts index 3b32ff8a2f4..60080c4a533 100644 --- a/apps/sim/lib/copilot/request/session/contract.test.ts +++ b/apps/sim/lib/copilot/request/session/contract.test.ts @@ -255,4 +255,24 @@ describe('resource event view pins', () => { expect(isContractStreamEventEnvelope(event)).toBe(false) }) + + it('accepts an explicit pin clear and rejects a non-boolean directive', () => { + const event = { + ...BASE_ENVELOPE, + type: 'resource' as const, + payload: { + op: 'upsert' as const, + resource: { id: 'tbl-1', type: 'table', title: 'Invoices', clearViewId: true }, + }, + } + + expect(isContractStreamEventEnvelope(event)).toBe(true) + expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true) + expect( + isContractStreamEventEnvelope({ + ...event, + payload: { ...event.payload, resource: { ...event.payload.resource, clearViewId: 'yes' } }, + }) + ).toBe(false) + }) }) diff --git a/apps/sim/lib/copilot/request/session/contract.ts b/apps/sim/lib/copilot/request/session/contract.ts index a514b285b13..a0a4fc6474e 100644 --- a/apps/sim/lib/copilot/request/session/contract.ts +++ b/apps/sim/lib/copilot/request/session/contract.ts @@ -276,7 +276,8 @@ function isValidResourcePayload(payload: JsonRecord): boolean { return ( hasAddressableId(resource.id) && typeof resource.type === 'string' && - (resource.viewId === undefined || typeof resource.viewId === 'string') + (resource.viewId === undefined || typeof resource.viewId === 'string') && + (resource.clearViewId === undefined || typeof resource.clearViewId === 'boolean') ) } diff --git a/apps/sim/lib/copilot/request/tools/resources.test.ts b/apps/sim/lib/copilot/request/tools/resources.test.ts new file mode 100644 index 00000000000..fefe636c49e --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resources.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + extractResourcesFromToolResult: vi.fn(), + persistChatResources: vi.fn(() => Promise.resolve()), + setAttributes: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/otel', () => ({ + withCopilotSpan: ( + _name: string, + _attributes: Record, + run: (span: { setAttributes: typeof mocks.setAttributes }) => Promise + ) => run({ setAttributes: mocks.setAttributes }), +})) + +vi.mock('@/lib/copilot/resources/persistence', () => ({ + extractDeletedResourcesFromToolResult: vi.fn(() => []), + extractResourcesFromToolResult: mocks.extractResourcesFromToolResult, + hasDeleteCapability: vi.fn(() => false), + isResourceToolName: vi.fn(() => true), + persistChatResources: mocks.persistChatResources, + removeChatResources: vi.fn(() => Promise.resolve()), +})) + +import { + MothershipStreamV1EventType, + MothershipStreamV1ResourceOp, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' + +describe('handleResourceSideEffects', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('persists and emits the explicit saved-view pin clear directive', async () => { + mocks.extractResourcesFromToolResult.mockReturnValue([ + { + type: 'table', + id: 'tbl-1', + title: 'Invoices', + clearViewId: true, + }, + ]) + const onEvent = vi.fn() + + await handleResourceSideEffects( + 'table_views', + { operation: 'delete_view', args: { tableId: 'tbl-1', viewId: 'view-1' } }, + { success: true, output: {} }, + { success: true, output: {} }, + 'chat-1', + onEvent, + () => false + ) + + expect(mocks.persistChatResources).toHaveBeenCalledWith('chat-1', [ + { + type: 'table', + id: 'tbl-1', + title: 'Invoices', + clearViewId: true, + }, + ]) + expect(onEvent).toHaveBeenCalledWith({ + type: MothershipStreamV1EventType.resource, + payload: { + op: MothershipStreamV1ResourceOp.upsert, + resource: { + type: 'table', + id: 'tbl-1', + title: 'Invoices', + clearViewId: true, + }, + }, + }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/resources.ts b/apps/sim/lib/copilot/request/tools/resources.ts index ef0de8290d0..0646f444dcb 100644 --- a/apps/sim/lib/copilot/request/tools/resources.ts +++ b/apps/sim/lib/copilot/request/tools/resources.ts @@ -120,6 +120,7 @@ export async function handleResourceSideEffects( : {}), // An id, never secret material — read from the raw result. ...(resource.viewId !== undefined ? { viewId: resource.viewId } : {}), + ...(resource.clearViewId === true ? { clearViewId: true as const } : {}), })) : [] @@ -148,6 +149,7 @@ export async function handleResourceSideEffects( id: resource.id, title: resource.title, ...(resource.viewId !== undefined ? { viewId: resource.viewId } : {}), + ...(resource.clearViewId === true ? { clearViewId: true } : {}), }, }, }) diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index 49e70a29750..4072f69d9e1 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -220,7 +220,7 @@ describe('extractResourcesFromToolResult for table_views', () => { } ) - it('a delete opens the table without a pin', () => { + it('a delete opens the table and explicitly clears its saved pin', () => { expect( extractResourcesFromToolResult( 'table_views', @@ -231,7 +231,7 @@ describe('extractResourcesFromToolResult for table_views', () => { data: { tableId: 'tbl_1', tableName: 'Invoices' }, } ) - ).toEqual([{ type: 'table', id: 'tbl_1', title: 'Invoices' }]) + ).toEqual([{ type: 'table', id: 'tbl_1', title: 'Invoices', clearViewId: true }]) }) it.each(['list_views', 'get_view'])('%s opens nothing', (operation) => { diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index cc555b3c140..258cd36ea89 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -16,9 +16,9 @@ import { TableViews, UserTable, } from '@/lib/copilot/generated/tool-catalog-v1' -import type { MothershipResource, MothershipResourceType } from './types' +import type { MothershipResourceType, MothershipResourceUpdate } from './types' -type ChatResource = MothershipResource +type ChatResource = MothershipResourceUpdate type ResourceType = MothershipResourceType const RESOURCE_TOOL_NAMES: Set = new Set([ @@ -203,7 +203,8 @@ export function extractResourcesFromToolResult( // create/update/set-default — the view, so the panel opens the table pinned // to that view; a delete opens the table unpinned. Reads open nothing. case TableViews.id: { - if (READ_ONLY_VIEW_OPS.has(getOperation(params) ?? '')) return [] + const operation = getOperation(params) ?? '' + if (READ_ONLY_VIEW_OPS.has(operation)) return [] const args = toRecord(params?.args) const tableId = (data.tableId as string) ?? (args.tableId as string) if (!tableId) return [] @@ -214,6 +215,7 @@ export function extractResourcesFromToolResult( id: tableId, title: (data.tableName as string) || 'Table', ...(typeof viewId === 'string' && viewId ? { viewId } : {}), + ...(operation === 'delete_view' ? { clearViewId: true as const } : {}), }, ] } diff --git a/apps/sim/lib/copilot/resources/persistence.ts b/apps/sim/lib/copilot/resources/persistence.ts index c47ad6200ef..ab73cdbd2c2 100644 --- a/apps/sim/lib/copilot/resources/persistence.ts +++ b/apps/sim/lib/copilot/resources/persistence.ts @@ -3,7 +3,12 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { eq, sql } from 'drizzle-orm' -import { type MothershipResource, mergeChatResource, sanitizeChatResources } from './types' +import { + type MothershipResource, + type MothershipResourceUpdate, + mergeChatResource, + sanitizeChatResources, +} from './types' export { extractDeletedResourcesFromToolResult, @@ -26,7 +31,7 @@ type ChatResource = MothershipResource */ export async function persistChatResources( chatId: string, - newResources: ChatResource[] + newResources: MothershipResourceUpdate[] ): Promise { const toMerge = newResources.filter((r) => r.id !== 'streaming-file') if (toMerge.length === 0) return diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/copilot/resources/types.test.ts index 99dcf36c43d..2a7dcb6ce5d 100644 --- a/apps/sim/lib/copilot/resources/types.test.ts +++ b/apps/sim/lib/copilot/resources/types.test.ts @@ -111,6 +111,30 @@ describe('client and server agree on what can be persisted', () => { } }) + it('accepts an explicit table pin clear and rejects ambiguous or non-table clears', () => { + expect( + addCopilotChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { type: 'table', id: 'tbl-1', title: 'Invoices' }, + clearViewId: true, + }).success + ).toBe(true) + expect( + addCopilotChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { type: 'table', id: 'tbl-1', title: 'Invoices', viewId: 'view-1' }, + clearViewId: true, + }).success + ).toBe(false) + expect( + addCopilotChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { type: 'file', id: 'file-1', title: 'report.csv' }, + clearViewId: true, + }).success + ).toBe(false) + }) + it('covers every resource type, so a new one has to make the choice explicitly', () => { const all = Object.values(MothershipResourceType) const ephemeral = all.filter((type) => isEphemeralResource(resource({ type }))) @@ -182,6 +206,13 @@ describe('mergeChatResource', () => { // A row edit re-adds the table without a view — the tab stays on view-b. expect(mergeChatResource(pinnedB, stored)).toBe(pinnedB) }) + + it('clears a pin only when the update carries the explicit clear directive', () => { + const pinned = { ...stored, viewId: 'view-a' } + + expect(mergeChatResource(pinned, { ...stored, clearViewId: true })).toEqual(stored) + expect(mergeChatResource(undefined, { ...stored, clearViewId: true })).toEqual(stored) + }) }) describe('mergeChatResource metadata', () => { diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 3bc585c8235..67c0b641452 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -30,6 +30,12 @@ export interface MothershipResource { executionId?: string } +/** A resource upsert may explicitly clear metadata that omission preserves. */ +export interface MothershipResourceUpdate extends MothershipResource { + /** Removes a table's saved-view pin instead of preserving it. */ + clearViewId?: true +} + /** * What a chip in an assistant message knows about the resource it points at, * before it has been resolved. The agent writes these tags as text, so a file @@ -223,13 +229,18 @@ export const GENERIC_RESOURCE_TITLES = new Set([ */ export function mergeChatResource( prev: MothershipResource | undefined, - next: MothershipResource + next: MothershipResourceUpdate ): MothershipResource { - if (!prev) return next + if (!prev) { + if (next.clearViewId !== true) return next + const { clearViewId: _clearViewId, ...resource } = next + return resource + } + const { viewId: _previousViewId, ...prevWithoutViewId } = prev const merged: MothershipResource = { - ...prev, + ...(next.clearViewId === true ? prevWithoutViewId : prev), ...(next.path !== undefined ? { path: next.path } : {}), - ...(next.viewId !== undefined ? { viewId: next.viewId } : {}), + ...(next.clearViewId !== true && next.viewId !== undefined ? { viewId: next.viewId } : {}), ...(next.executionId !== undefined ? { executionId: next.executionId } : {}), title: GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(next.title) diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 822830b868a..8009d4efd24 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -192,7 +192,10 @@ describe('table-view mutations signal collaborators', () => { isDefault: true, }) - expect(dbChainMockFns.set).toHaveBeenCalledWith({ isDefault: false }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + isDefault: false, + updatedAt: expect.any(Date), + }) expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ isDefault: true })) }) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 7d2df73182b..4e9b4f61197 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -482,7 +482,7 @@ export async function createTableView(data: CreateTableViewData): Promise 0) { await trx .update(tableViews) - .set({ isDefault: false }) + .set({ isDefault: false, updatedAt: new Date() }) .where( and( eq(tableViews.tableId, data.tableId), diff --git a/apps/sim/stores/table/view-pin/store.test.ts b/apps/sim/stores/table/view-pin/store.test.ts index c7eefd0c659..80b9cea40f7 100644 --- a/apps/sim/stores/table/view-pin/store.test.ts +++ b/apps/sim/stores/table/view-pin/store.test.ts @@ -49,4 +49,15 @@ describe('useTableViewPinStore', () => { useTableViewPinStore.getState().consume('tbl-none', 1) expect(useTableViewPinStore.getState().pins).toBe(before) }) + + it('clear removes a pending pin and leaves other tables alone', () => { + const { pin, clear } = useTableViewPinStore.getState() + pin('tbl-1', 'view-a') + pin('tbl-2', 'view-b') + + clear('tbl-1') + + expect(useTableViewPinStore.getState().pins['tbl-1']).toBeUndefined() + expect(useTableViewPinStore.getState().pins['tbl-2'].viewId).toBe('view-b') + }) }) diff --git a/apps/sim/stores/table/view-pin/store.ts b/apps/sim/stores/table/view-pin/store.ts index d66d3c8e2b8..f7f9212bf06 100644 --- a/apps/sim/stores/table/view-pin/store.ts +++ b/apps/sim/stores/table/view-pin/store.ts @@ -14,6 +14,8 @@ interface TableViewPinState { nextSeq: number /** Asks the table to open on `viewId`; replaces any pin still pending for it. */ pin: (tableId: string, viewId: string) => void + /** Clears any pending pin after the referenced view is deleted. */ + clear: (tableId: string) => void /** Clears a pin the table has applied. A newer pin (higher seq) issued meanwhile is kept. */ consume: (tableId: string, seq: number) => void reset: () => void @@ -41,6 +43,12 @@ export const useTableViewPinStore = create()( pins: { ...state.pins, [tableId]: { viewId, seq: state.nextSeq } }, nextSeq: state.nextSeq + 1, })), + clear: (tableId) => + set((state) => { + if (!state.pins[tableId]) return state + const { [tableId]: _cleared, ...pins } = state.pins + return { pins } + }), consume: (tableId, seq) => set((state) => { const pending = state.pins[tableId] From 838c936da07324c8f630ccc6c033b924a34cbd0c Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:12:52 -0700 Subject: [PATCH 07/17] fix(tables): reconcile agent view pins --- .../[workspaceId]/tables/[tableId]/table.tsx | 14 +++++++++---- .../tables/[tableId]/view-state.test.ts | 15 +++++++++++++ .../tables/[tableId]/view-state.ts | 21 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 8bf69400bea..392610192ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -44,6 +44,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { getTableViewRevision, resolveTableViewConfig, + resolveTableViewPinTransition, resolveTableViewSelection, shouldApplyTableViewRevision, type TableViewRevision, @@ -719,11 +720,16 @@ export function Table({ if (appliedViewRevisionRef.current === undefined) return if (!views.some((view) => view.id === viewPin.viewId)) return consumeViewPin(tableId, viewPin.seq) - if (activeViewId === viewPin.viewId || appliedViewRevisionRef.current.id === viewPin.viewId) { - return - } + const transition = resolveTableViewPinTransition( + activeViewId, + appliedViewRevisionRef.current.id, + viewPin.viewId, + pendingCreatedViewIdRef.current + ) + if (!transition.nextViewId) return + pendingCreatedViewIdRef.current = transition.pendingCreatedViewId preservedViewStateRef.current = null - setTableParams({ view: viewPin.viewId }) + setTableParams({ view: transition.nextViewId }) }, [embedded, viewPin, views, activeViewId, tableId, consumeViewPin, setTableParams]) /** diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts index 7cbc4cc5927..25af45dab29 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts @@ -7,6 +7,7 @@ import { ALL_VIEW_PARAM } from '@/app/workspace/[workspaceId]/tables/[tableId]/s import { getTableViewRevision, resolveTableViewConfig, + resolveTableViewPinTransition, resolveTableViewSelection, shouldApplyTableViewRevision, } from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state' @@ -79,6 +80,20 @@ describe('resolveTableViewSelection', () => { }) }) +describe('resolveTableViewPinTransition', () => { + it('abandons a pending local creation when an external pin replaces its URL selection', () => { + expect( + resolveTableViewPinTransition('view-old', 'view-created', 'view-pinned', 'view-created') + ).toEqual({ nextViewId: 'view-pinned', pendingCreatedViewId: null }) + }) + + it('keeps the pending creation when the pin is already represented locally', () => { + expect( + resolveTableViewPinTransition('view-pinned', 'view-created', 'view-pinned', 'view-created') + ).toEqual({ nextViewId: null, pendingCreatedViewId: 'view-created' }) + }) +}) + describe('shouldApplyTableViewRevision', () => { const cached = { id: 'view-1', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts index 0903d6aa4cd..c2f9c28dbe6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts @@ -49,6 +49,27 @@ export interface TableViewRevision { updatedAt: number | null } +export interface TableViewPinTransition { + nextViewId: string | null + pendingCreatedViewId: string | null +} + +/** + * Resolves an external saved-view pin without leaving a locally created view + * waiting for a URL selection that the pin is about to replace. + */ +export function resolveTableViewPinTransition( + activeViewId: string | null, + appliedViewId: string | null, + pinnedViewId: string, + pendingCreatedViewId: string | null +): TableViewPinTransition { + if (activeViewId === pinnedViewId || appliedViewId === pinnedViewId) { + return { nextViewId: null, pendingCreatedViewId } + } + return { nextViewId: pinnedViewId, pendingCreatedViewId: null } +} + export function getTableViewRevision( view: Pick | null ): TableViewRevision { From dac94a323e0e1b74235445aeb94bd30663cab5fd Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:18:40 -0700 Subject: [PATCH 08/17] fix(copilot): type resource update directives --- apps/sim/lib/copilot/tool-executor/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 47db6aa0d95..3bfcfd87da7 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -1,5 +1,5 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -84,7 +84,7 @@ export interface ToolExecutionResult { success: boolean output?: unknown error?: string - resources?: MothershipResource[] + resources?: MothershipResourceUpdate[] /** * Declared by tools whose failure a caller cannot otherwise act on. Consumed by * the egress projection and never returned to the model as-is — on a withheld From e2d228465dd33f7aca23d4ce7ca991193040b97f Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:26:16 -0700 Subject: [PATCH 09/17] fix(tables): serialize default view demotions --- apps/sim/lib/table/views/service.test.ts | 9 +++++++++ apps/sim/lib/table/views/service.ts | 10 +++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 8009d4efd24..29b0e114b54 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -764,6 +764,15 @@ describe('default-view writers share the views lock', () => { expect(dbChainMockFns.execute).toHaveBeenCalled() }) + it('demoting a view takes the same advisory lock as other default-state writers', async () => { + queueTableRows(tableViews, [{ ...viewRow, isDefault: true }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, isDefault: false }]) + + await updateTableView({ viewId: 'view-1', tableId: 'table-1', isDefault: false, columns }) + + expect(dbChainMockFns.execute).toHaveBeenCalled() + }) + it('a rename stays a plain transaction, off the lock', async () => { queueTableRows(tableViews, [{ id: 'view-1' }]) dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, name: 'Renamed' }]) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 4e9b4f61197..e538454adae 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -541,14 +541,14 @@ export interface UpdateTableViewData { * the references that row already carries stay writable — see * {@link normalizeViewConfigForStorage}. * - * Promotion demotes siblings, so it contends with {@link createTableView}'s - * default-on-create path; both serialize on the same per-table views lock, or - * the partial unique index fails one of two valid writes. Plain patches (layout - * autosave, renames) touch only their own row and skip the lock. + * Every explicit default-state change contends with {@link createTableView}'s + * default-on-create path, so promotions and demotions serialize on the same + * per-table views lock. Plain patches (layout autosave, renames) touch only + * their own row and skip the lock. */ export async function updateTableView(data: UpdateTableViewData): Promise { const runWrite = (write: (trx: DbTransaction) => Promise): Promise => - data.isDefault === true ? withTableViewsLock(data.tableId, write) : db.transaction(write) + data.isDefault !== undefined ? withTableViewsLock(data.tableId, write) : db.transaction(write) const outcome = await runWrite(async (tx) => { // Confirm the target exists BEFORE demoting. The demotion has to run first — // the partial unique index rejects a second default — but on a PATCH naming a From 0a0b72a69733ef6060736d5044399309a2c8709e Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:35:45 -0700 Subject: [PATCH 10/17] fix(copilot): preserve view pin clear requests --- .../api/contracts/mothership-chats.test.ts | 43 +++++++++++++++++++ .../sim/lib/api/contracts/mothership-chats.ts | 7 ++- 2 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 apps/sim/lib/api/contracts/mothership-chats.test.ts diff --git a/apps/sim/lib/api/contracts/mothership-chats.test.ts b/apps/sim/lib/api/contracts/mothership-chats.test.ts new file mode 100644 index 00000000000..cd3c072dd15 --- /dev/null +++ b/apps/sim/lib/api/contracts/mothership-chats.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { addMothershipChatResourceBodySchema } from '@/lib/api/contracts/mothership-chats' + +const TABLE_RESOURCE = { + type: 'table' as const, + id: 'table-1', + title: 'Accounts', +} + +describe('addMothershipChatResourceBodySchema', () => { + it('preserves an explicit saved-view pin clear through outbound parsing', () => { + expect( + addMothershipChatResourceBodySchema.parse({ + chatId: 'chat-1', + resource: TABLE_RESOURCE, + clearViewId: true, + }) + ).toEqual({ chatId: 'chat-1', resource: TABLE_RESOURCE, clearViewId: true }) + }) + + it('rejects a clear directive for a non-table resource', () => { + expect( + addMothershipChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { type: 'file', id: 'file-1', title: 'Accounts.csv' }, + clearViewId: true, + }).success + ).toBe(false) + }) + + it('rejects a clear directive paired with a replacement pin', () => { + expect( + addMothershipChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { ...TABLE_RESOURCE, viewId: 'view-1' }, + clearViewId: true, + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index dab9fc9ad3d..a46f4f6ac8f 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { addCopilotChatResourceBodySchema } from '@/lib/api/contracts/copilot' import { scheduleContextSchema } from '@/lib/api/contracts/schedules' import { mountedSecretNamesSchema, @@ -210,10 +211,8 @@ const mothershipChatResourcesResponseSchema = z.object({ resources: z.array(mothershipChatResourceItemSchema), }) -const addMothershipChatResourceBodySchema = z.object({ - chatId: z.string().min(1), - resource: mothershipChatResourceItemSchema, -}) +export const addMothershipChatResourceBodySchema = addCopilotChatResourceBodySchema +export type AddMothershipChatResourceBody = z.input const reorderMothershipChatResourcesBodySchema = z.object({ chatId: z.string().min(1), From 29341bf97bc658eed32caeef1433c4978a04dc0a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:57:40 -0700 Subject: [PATCH 11/17] fix(copilot): serialize resource view updates --- .../app/api/copilot/chat/resources/route.ts | 13 +- .../[workspaceId]/home/hooks/use-chat.ts | 275 +++++++----------- .../tables/[tableId]/view-state.test.ts | 14 + .../tables/[tableId]/view-state.ts | 2 +- apps/sim/lib/api/contracts/copilot.ts | 2 +- .../api/contracts/mothership-chats.test.ts | 9 + .../client-persistence-queue.test.ts | 94 ++++++ .../resources/client-persistence-queue.ts | 134 +++++++++ .../lib/copilot/resources/persistence.test.ts | 48 +++ apps/sim/lib/copilot/resources/persistence.ts | 130 +++++---- apps/sim/lib/copilot/resources/types.test.ts | 50 ++++ apps/sim/lib/copilot/resources/types.ts | 50 ++++ apps/sim/stores/table/view-pin/store.test.ts | 10 + apps/sim/stores/table/view-pin/store.ts | 3 + 14 files changed, 606 insertions(+), 228 deletions(-) create mode 100644 apps/sim/lib/copilot/resources/client-persistence-queue.test.ts create mode 100644 apps/sim/lib/copilot/resources/client-persistence-queue.ts create mode 100644 apps/sim/lib/copilot/resources/persistence.test.ts diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index ae5186870d7..8f14cc7c8f5 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -21,6 +21,7 @@ import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' import { canonicalizeDesktopSessionResource, mergeChatResource, + reorderStoredChatResources, sanitizeChatResources, } from '@/lib/copilot/resources/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -140,16 +141,8 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => { const existing = sanitizeChatResources( Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] ) - // The client echoes the tabs it holds; anything it does not carry (a view - // pin, a path) is taken from the stored entry rather than dropped. - const existingByKey = new Map(existing.map((r) => [`${r.type}:${r.id}`, r])) - const canonicalOrder = sanitizeChatResources(newOrder).map((r) => - mergeChatResource(existingByKey.get(`${r.type}:${r.id}`), r) - ) - const existingKeys = new Set(existingByKey.keys()) - const newKeys = new Set(canonicalOrder.map((r) => `${r.type}:${r.id}`)) - - if (existingKeys.size !== newKeys.size || ![...existingKeys].every((k) => newKeys.has(k))) { + const canonicalOrder = reorderStoredChatResources(existing, newOrder) + if (!canonicalOrder) { return createBadRequestResponse('Reordered resources must match existing resources') } 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 49690c07f7c..bf8ed4eccc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -66,6 +66,7 @@ import { } from '@/lib/copilot/request/session/file-preview-session-contract' import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' import { canDisplayResource } from '@/lib/copilot/resources/availability' +import { ResourcePersistenceQueue } from '@/lib/copilot/resources/client-persistence-queue' import { BROWSER_SESSION_RESOURCE_ID, isAddressableResource, @@ -1384,9 +1385,27 @@ export function useChat( * make the tabs disappear for the desktop app too. */ const undisplayableResourcesRef = useRef([]) - const pendingPersistResourceKeysRef = useRef>(new Set()) - const pendingClearViewIdKeysRef = useRef>(new Set()) - const inFlightResourceAddsRef = useRef>>(new Map()) + const resourcePersistenceQueueRef = useRef(null) + if (!resourcePersistenceQueueRef.current) { + resourcePersistenceQueueRef.current = new ResourcePersistenceQueue({ + persist: (chatId, update) => { + const { clearViewId, ...resource } = update + return requestJson(addMothershipChatResourceContract, { + body: { + chatId, + resource, + ...(clearViewId === true ? { clearViewId: true as const } : {}), + }, + }) + }, + onError: (error) => { + logger.warn('Failed to persist resource; will retry on next hydration', error) + }, + }) + } + const resourcePersistenceQueue = resourcePersistenceQueueRef.current + const pendingPersistResourceKeysRef = useRef(resourcePersistenceQueue.pendingKeys) + const inFlightResourceAddsRef = useRef(resourcePersistenceQueue.inFlight) const reorderNeededAfterFlushRef = useRef(false) // Derive the effective active resource ID for rendering without writing a @@ -1644,9 +1663,7 @@ export function useChat( // Pending view pins belong to the chat whose stream issued them. useTableViewPinStore.getState().reset() undisplayableResourcesRef.current = [] - pendingPersistResourceKeysRef.current.clear() - pendingClearViewIdKeysRef.current.clear() - inFlightResourceAddsRef.current.clear() + resourcePersistenceQueue.clear() reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() // Editing binds to this hook's composer — release it before rotating chatKey. @@ -1674,56 +1691,29 @@ export function useChat( workspaceId, ]) - const flushPendingResources = useCallback(async (chatId: string) => { - const pendingKeys = pendingPersistResourceKeysRef.current - if (pendingKeys.size === 0) return - const flushPromises: Array> = [] - for (const resource of resourcesRef.current) { - if (resource.id === 'streaming-file') continue - const key = `${resource.type}:${resource.id}` - if (!pendingKeys.has(key)) continue - pendingKeys.delete(key) - const shouldClearViewId = pendingClearViewIdKeysRef.current.has(key) - const promise = requestJson(addMothershipChatResourceContract, { - body: { - chatId, - resource, - ...(shouldClearViewId ? { clearViewId: true as const } : {}), - }, + const flushPendingResources = useCallback( + async (chatId: string) => { + if (pendingPersistResourceKeysRef.current.size === 0) return + await resourcePersistenceQueue.flush(chatId) + if (!reorderNeededAfterFlushRef.current) return + reorderNeededAfterFlushRef.current = false + const localOrder = [ + ...resourcesRef.current.filter( + (r) => + r.id !== 'streaming-file' && + !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`) + ), + ...undisplayableResourcesRef.current, + ] + if (localOrder.length === 0) return + requestJson(reorderMothershipChatResourcesContract, { + body: { chatId, resources: localOrder }, + }).catch((err) => { + logger.warn('Failed to sync resource order after flush', err) }) - .then((result) => { - if (shouldClearViewId) pendingClearViewIdKeysRef.current.delete(key) - return result - }) - .catch((err) => { - pendingPersistResourceKeysRef.current.add(key) - logger.warn('Failed to flush pending resource; will retry on next hydration', err) - }) - .finally(() => { - inFlightResourceAddsRef.current.delete(key) - }) - inFlightResourceAddsRef.current.set(key, promise) - flushPromises.push(promise) - } - if (flushPromises.length === 0) return - await Promise.allSettled(flushPromises) - if (!reorderNeededAfterFlushRef.current) return - reorderNeededAfterFlushRef.current = false - const localOrder = [ - ...resourcesRef.current.filter( - (r) => - r.id !== 'streaming-file' && - !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`) - ), - ...undisplayableResourcesRef.current, - ] - if (localOrder.length === 0) return - requestJson(reorderMothershipChatResourcesContract, { - body: { chatId, resources: localOrder }, - }).catch((err) => { - logger.warn('Failed to sync resource order after flush', err) - }) - }, []) + }, + [resourcePersistenceQueue] + ) const adoptResolvedChatId = useCallback( (chatId: string, options?: { replaceHomeHistory?: boolean; invalidateList?: boolean }) => { @@ -1811,113 +1801,76 @@ export function useChat( const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current)) }, [chatHistory, pendingMessages]) - const addResource = useCallback((resourceUpdate: MothershipResourceUpdate): boolean => { - // The single fan-in for tab creation, so the invariant lives here. - if (!isAddressableResource(resourceUpdate)) { - logger.warn('Ignored a resource with no id', { - type: resourceUpdate.type, - title: resourceUpdate.title, - }) - return false - } - const existing = resourcesRef.current.find( - (r) => r.type === resourceUpdate.type && r.id === resourceUpdate.id - ) - const resource = mergeChatResource(existing, resourceUpdate) - if (existing && resource === existing) { - return false - } - - setResources((prev) => { - const current = prev.find((r) => r.type === resource.type && r.id === resource.id) - if (!current) return [...prev, resource] - const merged = mergeChatResource(current, resourceUpdate) - return merged === current - ? prev - : prev.map((r) => (r.type === resource.type && r.id === resource.id ? merged : r)) - }) - // Synthetic result/preview panels are in-memory only. The browser tab - // metadata is persisted even though its live page remains desktop-owned. - if (isEphemeralResource(resource)) { - return true - } + const addResource = useCallback( + (resourceUpdate: MothershipResourceUpdate): boolean => { + // The single fan-in for tab creation, so the invariant lives here. + if (!isAddressableResource(resourceUpdate)) { + logger.warn('Ignored a resource with no id', { + type: resourceUpdate.type, + title: resourceUpdate.title, + }) + return false + } + const existing = resourcesRef.current.find( + (r) => r.type === resourceUpdate.type && r.id === resourceUpdate.id + ) + const resource = mergeChatResource(existing, resourceUpdate) + if (existing && resource === existing) { + return false + } - const persistChatId = chatIdRef.current ?? selectedChatIdRef.current - const key = `${resource.type}:${resource.id}` - const shouldClearViewId = resourceUpdate.clearViewId === true - if (shouldClearViewId) { - pendingClearViewIdKeysRef.current.add(key) - } else if (resourceUpdate.viewId !== undefined) { - pendingClearViewIdKeysRef.current.delete(key) - } - // `resourcesRef` is written during render, so adds of the same resource in - // one tick all read the pre-render list and all pass the check above. State - // converges (the updater is idempotent) but each fired its own POST — 5-6 - // per resource in production. - const alreadyPersisting = - inFlightResourceAddsRef.current.has(key) || pendingPersistResourceKeysRef.current.has(key) - if (alreadyPersisting) { - pendingPersistResourceKeysRef.current.add(key) - return existing === undefined - } - if (persistChatId) { - const promise = requestJson(addMothershipChatResourceContract, { - body: { - chatId: persistChatId, - resource, - ...(shouldClearViewId ? { clearViewId: true as const } : {}), - }, + setResources((prev) => { + const current = prev.find((r) => r.type === resource.type && r.id === resource.id) + if (!current) return [...prev, resource] + const merged = mergeChatResource(current, resourceUpdate) + return merged === current + ? prev + : prev.map((r) => (r.type === resource.type && r.id === resource.id ? merged : r)) }) - .then((result) => { - if (shouldClearViewId) pendingClearViewIdKeysRef.current.delete(key) - return result - }) - .catch((err) => { - pendingPersistResourceKeysRef.current.add(key) - logger.warn('Failed to persist resource; will retry on next hydration', err) - }) - .finally(() => { - inFlightResourceAddsRef.current.delete(key) - }) - inFlightResourceAddsRef.current.set(key, promise) - } else { - pendingPersistResourceKeysRef.current.add(key) - } - return existing === undefined - }, []) + // Synthetic result/preview panels are in-memory only. The browser tab + // metadata is persisted even though its live page remains desktop-owned. + if (isEphemeralResource(resource)) { + return true + } - const removeResource = useCallback((resourceType: MothershipResourceType, resourceId: string) => { - setResources((prev) => prev.filter((r) => !(r.type === resourceType && r.id === resourceId))) - setActiveResourceId((prev) => (prev === resourceId ? null : prev)) + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + resourcePersistenceQueue.enqueue(resourceUpdate, persistChatId, existing) + return existing === undefined + }, + [resourcePersistenceQueue] + ) - // Ephemeral panels were never persisted; nothing to delete server-side. - if (isEphemeralResource({ type: resourceType, id: resourceId, title: '' })) return + const removeResource = useCallback( + (resourceType: MothershipResourceType, resourceId: string) => { + setResources((prev) => prev.filter((r) => !(r.type === resourceType && r.id === resourceId))) + setActiveResourceId((prev) => (prev === resourceId ? null : prev)) - const key = `${resourceType}:${resourceId}` - pendingClearViewIdKeysRef.current.delete(key) - const wasPending = pendingPersistResourceKeysRef.current.delete(key) - const inFlightAdd = inFlightResourceAddsRef.current.get(key) - if (wasPending && !inFlightAdd) return + // Ephemeral panels were never persisted; nothing to delete server-side. + if (isEphemeralResource({ type: resourceType, id: resourceId, title: '' })) return - const persistChatId = chatIdRef.current ?? selectedChatIdRef.current - if (!persistChatId) return - const fireDelete = () => { - requestJson(removeMothershipChatResourceContract, { - body: { chatId: persistChatId, resourceType, resourceId }, - }).catch((err) => { - logger.warn('Failed to persist resource removal', err) - }) - } - if (inFlightAdd) { - // Drop the entry now, not when the add settles: an add being deleted must - // not suppress a fresh add of the same resource. The chained delete keeps - // its own reference to the promise. - inFlightResourceAddsRef.current.delete(key) - inFlightAdd.finally(fireDelete) - } else { - fireDelete() - } - }, []) + const { inFlight: inFlightAdd, wasPending } = resourcePersistenceQueue.remove( + resourceType, + resourceId + ) + if (wasPending && !inFlightAdd) return + + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + if (!persistChatId) return + const fireDelete = () => { + requestJson(removeMothershipChatResourceContract, { + body: { chatId: persistChatId, resourceType, resourceId }, + }).catch((err) => { + logger.warn('Failed to persist resource removal', err) + }) + } + if (inFlightAdd) { + inFlightAdd.finally(fireDelete) + } else { + fireDelete() + } + }, + [resourcePersistenceQueue] + ) /** * Drops hydrated workflow tabs whose workflow no longer exists, so an old @@ -2280,11 +2233,7 @@ export function useChat( const streamOwnerId = chatIdRef.current const pendingTurn = activeTurnRef.current const pendingStreamId = streamIdRef.current ?? pendingTurn?.userMessageId - const pendingResources = resourcesRef.current.filter( - (resource) => - !isEphemeralResource(resource) && - pendingPersistResourceKeysRef.current.has(`${resource.type}:${resource.id}`) - ) + const pendingResources = resourcePersistenceQueue.getPendingUpdates() const navigatedToDifferentChat = sendingRef.current && initialChatId !== streamOwnerId && @@ -2382,9 +2331,7 @@ export function useChat( setResources([]) setActiveResourceId(null) useTableViewPinStore.getState().reset() - pendingPersistResourceKeysRef.current.clear() - pendingClearViewIdKeysRef.current.clear() - inFlightResourceAddsRef.current.clear() + resourcePersistenceQueue.clear() reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() // Rotate the bucket key; the previous chat's queue stays in the store. diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts index 25af45dab29..2979721bf30 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts @@ -92,6 +92,20 @@ describe('resolveTableViewPinTransition', () => { resolveTableViewPinTransition('view-pinned', 'view-created', 'view-pinned', 'view-created') ).toEqual({ nextViewId: null, pendingCreatedViewId: 'view-created' }) }) + + it('replaces a different active URL even if the pin was applied previously', () => { + expect(resolveTableViewPinTransition('view-user', 'view-pinned', 'view-pinned', null)).toEqual({ + nextViewId: 'view-pinned', + pendingCreatedViewId: null, + }) + }) + + it('suppresses a redundant URL update while the applied view has no URL selection', () => { + expect(resolveTableViewPinTransition(null, 'view-pinned', 'view-pinned', null)).toEqual({ + nextViewId: null, + pendingCreatedViewId: null, + }) + }) }) describe('shouldApplyTableViewRevision', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts index c2f9c28dbe6..c702e6e215b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts @@ -64,7 +64,7 @@ export function resolveTableViewPinTransition( pinnedViewId: string, pendingCreatedViewId: string | null ): TableViewPinTransition { - if (activeViewId === pinnedViewId || appliedViewId === pinnedViewId) { + if (activeViewId === pinnedViewId || (activeViewId === null && appliedViewId === pinnedViewId)) { return { nextViewId: null, pendingCreatedViewId } } return { nextViewId: pinnedViewId, pendingCreatedViewId: null } diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index e726c51bac7..5ecf11037b0 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -102,7 +102,7 @@ const copilotChatResourceItemSchema = z.object({ export const addCopilotChatResourceBodySchema = z .object({ - chatId: z.string(), + chatId: requiredFieldSchema('chatId cannot be empty'), resource: copilotChatResourceItemSchema, clearViewId: z.literal(true).optional(), }) diff --git a/apps/sim/lib/api/contracts/mothership-chats.test.ts b/apps/sim/lib/api/contracts/mothership-chats.test.ts index cd3c072dd15..c2fad96fa1b 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.test.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.test.ts @@ -11,6 +11,15 @@ const TABLE_RESOURCE = { } describe('addMothershipChatResourceBodySchema', () => { + it('rejects an empty chat id', () => { + expect( + addMothershipChatResourceBodySchema.safeParse({ + chatId: '', + resource: TABLE_RESOURCE, + }).success + ).toBe(false) + }) + it('preserves an explicit saved-view pin clear through outbound parsing', () => { expect( addMothershipChatResourceBodySchema.parse({ diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts new file mode 100644 index 00000000000..9e6e6e02d8e --- /dev/null +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ResourcePersistenceQueue } from '@/lib/copilot/resources/client-persistence-queue' +import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' + +function deferred() { + let resolve: (value: T) => void = () => {} + let reject: (error: unknown) => void = () => {} + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +const TABLE_RESOURCE: MothershipResourceUpdate = { + type: 'table', + id: 'table-1', + title: 'Accounts', +} + +describe('ResourcePersistenceQueue', () => { + const onError = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('drains a newer update after the write for the same resource settles', async () => { + const first = deferred() + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ success: true }) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') + + await Promise.resolve() + expect(persist).toHaveBeenCalledTimes(1) + const flushed = queue.flush('chat-1') + first.resolve({ success: true }) + await flushed + + expect(persist).toHaveBeenCalledTimes(2) + expect(persist.mock.calls[1]).toEqual(['chat-1', { ...TABLE_RESOURCE, viewId: 'view-b' }]) + expect(queue.pendingKeys.size).toBe(0) + expect(queue.inFlight.size).toBe(0) + }) + + it('retains the newest desired state after a failure for a later retry', async () => { + const first = deferred() + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ success: true }) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue({ ...TABLE_RESOURCE, clearViewId: true }, 'chat-1') + queue.enqueue(TABLE_RESOURCE, 'chat-1') + first.reject(new Error('offline')) + await Promise.allSettled(Array.from(queue.inFlight.values())) + + expect(queue.getPendingUpdates()).toEqual([{ ...TABLE_RESOURCE, clearViewId: true }]) + await queue.flush('chat-1') + + expect(persist.mock.calls[1]).toEqual(['chat-1', { ...TABLE_RESOURCE, clearViewId: true }]) + expect(onError).toHaveBeenCalledOnce() + }) + + it('does not let a removed write settle over a fresh add of the same resource', async () => { + const stale = deferred() + const fresh = deferred() + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(fresh.promise) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1') + queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') + stale.resolve({ success: true }) + await Promise.resolve() + + expect(queue.inFlight.size).toBe(1) + fresh.resolve({ success: true }) + await Promise.allSettled(Array.from(queue.inFlight.values())) + expect(queue.inFlight.size).toBe(0) + }) +}) diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.ts new file mode 100644 index 00000000000..aaedcb5e4aa --- /dev/null +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.ts @@ -0,0 +1,134 @@ +import type { MothershipResource, MothershipResourceUpdate } from '@/lib/copilot/resources/types' +import { mergePendingChatResourceUpdate } from '@/lib/copilot/resources/types' + +interface ResourcePersistenceQueueOptions { + persist: (chatId: string, update: MothershipResourceUpdate) => Promise + onError: (error: unknown) => void +} + +export interface RemovedResourcePersistence { + inFlight: Promise | undefined + wasPending: boolean +} + +/** + * Serializes writes per resource while allowing unrelated resources to persist + * concurrently. Each key holds the newest desired state until its write + * succeeds, so an update arriving in flight is drained immediately afterward + * and a failed write remains available for the next hydration retry. + */ +export class ResourcePersistenceQueue { + readonly pendingKeys = new Set() + readonly inFlight = new Map>() + + private readonly desiredUpdates = new Map() + private readonly failedKeys = new Set() + private readonly writeTokens = new Map() + private readonly persist: ResourcePersistenceQueueOptions['persist'] + private readonly onError: ResourcePersistenceQueueOptions['onError'] + + constructor({ persist, onError }: ResourcePersistenceQueueOptions) { + this.persist = persist + this.onError = onError + } + + enqueue( + update: MothershipResourceUpdate, + chatId: string | undefined, + base?: MothershipResource + ): void { + const key = this.getKey(update) + const previous = this.desiredUpdates.get(key) ?? base + this.desiredUpdates.set(key, mergePendingChatResourceUpdate(previous, update)) + this.failedKeys.delete(key) + + if (!chatId || this.inFlight.has(key)) { + this.pendingKeys.add(key) + return + } + + this.start(key, chatId) + } + + async flush(chatId: string): Promise { + for (const key of this.pendingKeys) this.failedKeys.delete(key) + this.startPending(chatId) + + while (this.inFlight.size > 0) { + await Promise.allSettled(Array.from(this.inFlight.values())) + } + } + + remove(type: string, id: string): RemovedResourcePersistence { + const key = `${type}:${id}` + const wasPending = this.pendingKeys.delete(key) + const inFlight = this.inFlight.get(key) + this.inFlight.delete(key) + this.writeTokens.delete(key) + this.desiredUpdates.delete(key) + this.failedKeys.delete(key) + return { inFlight, wasPending } + } + + getPendingUpdates(): MothershipResourceUpdate[] { + return Array.from(this.pendingKeys).flatMap((key) => { + const update = this.desiredUpdates.get(key) + return update ? [update] : [] + }) + } + + clear(): void { + this.pendingKeys.clear() + this.inFlight.clear() + this.desiredUpdates.clear() + this.failedKeys.clear() + this.writeTokens.clear() + } + + private startPending(chatId: string): void { + for (const key of this.pendingKeys) { + if (!this.failedKeys.has(key) && !this.inFlight.has(key)) this.start(key, chatId) + } + } + + private start(key: string, chatId: string): void { + const update = this.desiredUpdates.get(key) + if (!update) { + this.pendingKeys.delete(key) + return + } + + this.pendingKeys.delete(key) + let succeeded = false + const token = Symbol(key) + const tracked = Promise.resolve() + .then(() => this.persist(chatId, update)) + .then((result) => { + succeeded = true + return result + }) + .catch((error) => { + if (this.writeTokens.get(key) !== token) return + this.pendingKeys.add(key) + this.failedKeys.add(key) + this.onError(error) + }) + .finally(() => { + if (this.writeTokens.get(key) !== token) return + this.writeTokens.delete(key) + this.inFlight.delete(key) + if (!succeeded) return + if (this.pendingKeys.has(key)) { + this.start(key, chatId) + return + } + if (this.desiredUpdates.get(key) === update) this.desiredUpdates.delete(key) + }) + this.writeTokens.set(key, token) + this.inFlight.set(key, tracked) + } + + private getKey(resource: Pick): string { + return `${resource.type}:${resource.id}` + } +} diff --git a/apps/sim/lib/copilot/resources/persistence.test.ts b/apps/sim/lib/copilot/resources/persistence.test.ts new file mode 100644 index 00000000000..799696b69f3 --- /dev/null +++ b/apps/sim/lib/copilot/resources/persistence.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { databaseMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { persistChatResources } from '@/lib/copilot/resources/persistence' + +function deferred() { + let resolve: () => void = () => {} + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +const transaction = databaseMock.db.transaction as ReturnType +const TABLE_RESOURCE = { type: 'table' as const, id: 'table-1', title: 'Accounts' } + +describe('persistChatResources ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('starts writes for the same chat in invocation order', async () => { + const first = deferred() + transaction.mockReturnValueOnce(first.promise).mockResolvedValueOnce(undefined) + + const firstWrite = persistChatResources('chat-1', [{ ...TABLE_RESOURCE, viewId: 'view-a' }]) + const secondWrite = persistChatResources('chat-1', [{ ...TABLE_RESOURCE, viewId: 'view-b' }]) + await vi.waitFor(() => expect(transaction).toHaveBeenCalledTimes(1)) + first.resolve() + await Promise.all([firstWrite, secondWrite]) + expect(transaction).toHaveBeenCalledTimes(2) + }) + + it('does not serialize writes for different chats', async () => { + const first = deferred() + const second = deferred() + transaction.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + + const firstWrite = persistChatResources('chat-1', [TABLE_RESOURCE]) + const secondWrite = persistChatResources('chat-2', [TABLE_RESOURCE]) + await vi.waitFor(() => expect(transaction).toHaveBeenCalledTimes(2)) + first.resolve() + second.resolve() + await Promise.all([firstWrite, secondWrite]) + }) +}) diff --git a/apps/sim/lib/copilot/resources/persistence.ts b/apps/sim/lib/copilot/resources/persistence.ts index ab73cdbd2c2..7bba594269b 100644 --- a/apps/sim/lib/copilot/resources/persistence.ts +++ b/apps/sim/lib/copilot/resources/persistence.ts @@ -8,23 +8,39 @@ import { type MothershipResourceUpdate, mergeChatResource, sanitizeChatResources, -} from './types' +} from '@/lib/copilot/resources/types' export { extractDeletedResourcesFromToolResult, extractResourcesFromToolResult, hasDeleteCapability, isResourceToolName, -} from './extraction' +} from '@/lib/copilot/resources/extraction' export type { MothershipResource as ChatResource, MothershipResourceType as ResourceType, -} from './types' +} from '@/lib/copilot/resources/types' const logger = createLogger('CopilotResources') type ChatResource = MothershipResource +const chatResourceWriteChain = new Map>() + +async function serializeChatResourceWrite( + chatId: string, + write: () => Promise +): Promise { + const tail = chatResourceWriteChain.get(chatId) ?? Promise.resolve() + const run = tail.catch(() => {}).then(write) + chatResourceWriteChain.set(chatId, run) + try { + await run + } finally { + if (chatResourceWriteChain.get(chatId) === run) chatResourceWriteChain.delete(chatId) + } +} + /** * Appends resources to a chat's JSONB resources column, deduplicating by type+id. * Updates the title of existing resources if the new title is more specific. @@ -37,34 +53,39 @@ export async function persistChatResources( if (toMerge.length === 0) return try { - const [chat] = await db - .select({ resources: copilotChats.resources }) - .from(copilotChats) - .where(eq(copilotChats.id, chatId)) - .limit(1) - - if (!chat) return - - const existing = sanitizeChatResources( - Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] - ) - const map = new Map() - - for (const r of existing) { - map.set(`${r.type}:${r.id}`, r) - } - - for (const r of sanitizeChatResources(toMerge)) { - const key = `${r.type}:${r.id}` - map.set(key, mergeChatResource(map.get(key), r)) - } - - const merged = Array.from(map.values()) - - await db - .update(copilotChats) - .set({ resources: sql`${JSON.stringify(merged)}::jsonb` }) - .where(eq(copilotChats.id, chatId)) + await serializeChatResourceWrite(chatId, async () => { + await db.transaction(async (tx) => { + const [chat] = await tx + .select({ resources: copilotChats.resources }) + .from(copilotChats) + .where(eq(copilotChats.id, chatId)) + .for('update') + .limit(1) + + if (!chat) return + + const existing = sanitizeChatResources( + Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] + ) + const map = new Map() + + for (const r of existing) { + map.set(`${r.type}:${r.id}`, r) + } + + for (const r of sanitizeChatResources(toMerge)) { + const key = `${r.type}:${r.id}` + map.set(key, mergeChatResource(map.get(key), r)) + } + + const merged = Array.from(map.values()) + + await tx + .update(copilotChats) + .set({ resources: sql`${JSON.stringify(merged)}::jsonb` }) + .where(eq(copilotChats.id, chatId)) + }) + }) } catch (err) { logger.warn('Failed to persist chat resources', { chatId, @@ -80,27 +101,32 @@ export async function removeChatResources(chatId: string, toRemove: ChatResource if (toRemove.length === 0) return try { - const [chat] = await db - .select({ resources: copilotChats.resources }) - .from(copilotChats) - .where(eq(copilotChats.id, chatId)) - .limit(1) - - if (!chat) return - - const stored = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] - const existing = sanitizeChatResources(stored) - const removeKeys = new Set(sanitizeChatResources(toRemove).map((r) => `${r.type}:${r.id}`)) - const filtered = existing.filter((r) => !removeKeys.has(`${r.type}:${r.id}`)) - - const removedSomething = filtered.length !== existing.length - const sanitizedSomething = existing.length !== stored.length - if (!removedSomething && !sanitizedSomething) return - - await db - .update(copilotChats) - .set({ resources: sql`${JSON.stringify(filtered)}::jsonb` }) - .where(eq(copilotChats.id, chatId)) + await serializeChatResourceWrite(chatId, async () => { + await db.transaction(async (tx) => { + const [chat] = await tx + .select({ resources: copilotChats.resources }) + .from(copilotChats) + .where(eq(copilotChats.id, chatId)) + .for('update') + .limit(1) + + if (!chat) return + + const stored = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] + const existing = sanitizeChatResources(stored) + const removeKeys = new Set(sanitizeChatResources(toRemove).map((r) => `${r.type}:${r.id}`)) + const filtered = existing.filter((r) => !removeKeys.has(`${r.type}:${r.id}`)) + + const removedSomething = filtered.length !== existing.length + const sanitizedSomething = existing.length !== stored.length + if (!removedSomething && !sanitizedSomething) return + + await tx + .update(copilotChats) + .set({ resources: sql`${JSON.stringify(filtered)}::jsonb` }) + .where(eq(copilotChats.id, chatId)) + }) + }) } catch (err) { logger.warn('Failed to remove chat resources', { chatId, diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/copilot/resources/types.test.ts index 2a7dcb6ce5d..33e36aa0f1f 100644 --- a/apps/sim/lib/copilot/resources/types.test.ts +++ b/apps/sim/lib/copilot/resources/types.test.ts @@ -9,7 +9,9 @@ import { type MothershipResource, MothershipResourceType, mergeChatResource, + mergePendingChatResourceUpdate, PERSISTED_RESOURCE_TYPES, + reorderStoredChatResources, sanitizeChatResources, TERMINAL_SESSION_RESOURCE_ID, } from './types' @@ -238,3 +240,51 @@ describe('mergeChatResource metadata', () => { ).toBe('exec-1') }) }) + +describe('mergePendingChatResourceUpdate', () => { + const table = resource({ type: 'table', id: 'tbl-1', title: 'Invoices' }) + + it('retains a pending clear across an unrelated update', () => { + expect(mergePendingChatResourceUpdate({ ...table, clearViewId: true }, table)).toEqual({ + ...table, + clearViewId: true, + }) + }) + + it('lets a newer explicit pin replace a pending clear', () => { + expect( + mergePendingChatResourceUpdate( + { ...table, clearViewId: true }, + { ...table, viewId: 'view-new' } + ) + ).toEqual({ ...table, viewId: 'view-new' }) + }) +}) + +describe('reorderStoredChatResources', () => { + const table = resource({ + type: 'table', + id: 'tbl-1', + title: 'Invoices', + viewId: 'view-new', + }) + const file = resource({ id: 'file-1', title: 'report.csv', path: 'files/report.csv' }) + + it('uses the request only for order and preserves newer stored metadata', () => { + expect( + reorderStoredChatResources( + [table, file], + [ + { ...file, path: 'stale/report.csv' }, + { ...table, viewId: 'view-stale' }, + ] + ) + ).toEqual([file, table]) + }) + + it('rejects missing, extra, and duplicate identities', () => { + expect(reorderStoredChatResources([table, file], [table])).toBeNull() + expect(reorderStoredChatResources([table], [table, file])).toBeNull() + expect(reorderStoredChatResources([table, file], [table, table])).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 67c0b641452..87011412e6c 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -208,6 +208,34 @@ export function sanitizeChatResources( return canonicalizeDesktopSessionResources(resources).filter(isAddressableResource) } +/** + * Applies a client-supplied order to the canonical stored entries. Reordering + * carries identity only: metadata echoed by a stale tab must never overwrite a + * newer pin, path, title, or execution id already persisted on the chat. + */ +export function reorderStoredChatResources( + storedResources: readonly MothershipResource[], + requestedOrder: readonly MothershipResource[] +): MothershipResource[] | null { + const stored = sanitizeChatResources(storedResources) + const requested = sanitizeChatResources(requestedOrder) + if (stored.length !== requested.length) return null + + const storedByKey = new Map( + stored.map((resource) => [`${resource.type}:${resource.id}`, resource]) + ) + const requestedKeys = requested.map((resource) => `${resource.type}:${resource.id}`) + if (new Set(requestedKeys).size !== storedByKey.size) return null + + const reordered: MothershipResource[] = [] + for (const key of requestedKeys) { + const resource = storedByKey.get(key) + if (!resource) return null + reordered.push(resource) + } + return reordered +} + /** Placeholder resource titles that a more specific title may overwrite during dedup. */ export const GENERIC_RESOURCE_TITLES = new Set([ 'Table', @@ -255,6 +283,28 @@ export function mergeChatResource( return unchanged ? prev : merged } +/** + * Coalesces durable updates that have not all reached the server yet. Unlike a + * stored resource, the pending value must retain an explicit pin-clear until a + * write succeeds; a later row edit that omits `viewId` must not cancel it. + */ +export function mergePendingChatResourceUpdate( + prev: MothershipResourceUpdate | undefined, + next: MothershipResourceUpdate +): MothershipResourceUpdate { + let previousClearViewId: true | undefined + let previousResource: MothershipResource | undefined + if (prev) { + const { clearViewId, ...resource } = prev + previousClearViewId = clearViewId + previousResource = resource + } + const merged = mergeChatResource(previousResource, next) + const shouldClearViewId = + next.viewId === undefined && (next.clearViewId === true || previousClearViewId === true) + return shouldClearViewId ? { ...merged, clearViewId: true } : merged +} + export const VFS_DIR_TO_RESOURCE: Record = { tables: 'table', files: 'file', diff --git a/apps/sim/stores/table/view-pin/store.test.ts b/apps/sim/stores/table/view-pin/store.test.ts index 80b9cea40f7..c78b32e4c88 100644 --- a/apps/sim/stores/table/view-pin/store.test.ts +++ b/apps/sim/stores/table/view-pin/store.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it } from 'vitest' import { useTableViewPinStore } from '@/stores/table/view-pin/store' +import { resetRegisteredUserData } from '@/stores/user-data-reset-registry' describe('useTableViewPinStore', () => { beforeEach(() => { @@ -60,4 +61,13 @@ describe('useTableViewPinStore', () => { expect(useTableViewPinStore.getState().pins['tbl-1']).toBeUndefined() expect(useTableViewPinStore.getState().pins['tbl-2'].viewId).toBe('view-b') }) + + it('clears pending pins when the authenticated identity changes', () => { + useTableViewPinStore.getState().pin('tbl-1', 'view-a') + + resetRegisteredUserData() + + expect(useTableViewPinStore.getState().pins).toEqual({}) + expect(useTableViewPinStore.getState().nextSeq).toBe(1) + }) }) diff --git a/apps/sim/stores/table/view-pin/store.ts b/apps/sim/stores/table/view-pin/store.ts index f7f9212bf06..75b489c8745 100644 --- a/apps/sim/stores/table/view-pin/store.ts +++ b/apps/sim/stores/table/view-pin/store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' +import { registerUserDataReset } from '@/stores/user-data-reset-registry' /** A request that the table switch to one of its saved views. */ export interface TableViewPin { @@ -61,3 +62,5 @@ export const useTableViewPinStore = create()( { name: 'table-view-pin-store' } ) ) + +registerUserDataReset('table-view-pin', () => useTableViewPinStore.getState().reset()) From 861c966071e66c2a4213209695156cee90fcfce1 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:20:31 -0700 Subject: [PATCH 12/17] fix(copilot): close resource persistence races --- .../app/api/copilot/chat/resources/route.ts | 169 +++++++++--------- .../[workspaceId]/home/hooks/use-chat.ts | 34 ++-- .../tables/[tableId]/view-state.test.ts | 10 +- .../tables/[tableId]/view-state.ts | 5 +- apps/sim/lib/api/contracts/copilot.ts | 24 ++- .../api/contracts/mothership-chats.test.ts | 9 + .../client-persistence-queue.test.ts | 68 ++++++- .../resources/client-persistence-queue.ts | 60 ++++++- .../lib/copilot/resources/persistence.test.ts | 18 +- apps/sim/lib/copilot/resources/persistence.ts | 10 +- 10 files changed, 288 insertions(+), 119 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 8f14cc7c8f5..0ec97cbe86b 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -16,7 +16,7 @@ import { createNotFoundResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' -import type { ChatResource } from '@/lib/copilot/resources/persistence' +import { type ChatResource, serializeChatResourceWrite } from '@/lib/copilot/resources/persistence' import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' import { canonicalizeDesktopSessionResource, @@ -55,44 +55,45 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true }) } - const [chat] = await db - .select({ resources: copilotChats.resources }) - .from(copilotChats) - .where( - and( + const merged = await serializeChatResourceWrite(chatId, () => + db.transaction(async (tx) => { + const scope = and( eq(copilotChats.id, chatId), eq(copilotChats.userId, userId), isNull(copilotChats.deletedAt) ) - ) - .limit(1) + const [chat] = await tx + .select({ resources: copilotChats.resources }) + .from(copilotChats) + .where(scope) + .for('update') + .limit(1) - if (!chat) { - return createNotFoundResponse('Chat not found or unauthorized') - } + if (!chat) return null - const existing = sanitizeChatResources( - Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] - ) - const key = `${resource.type}:${resource.id}` - const prev = existing.find((r) => `${r.type}:${r.id}` === key) - - const merged: ChatResource[] = prev - ? existing.map((r) => - `${r.type}:${r.id}` === key ? mergeChatResource(r, resourceUpdate) : r + const existing = sanitizeChatResources( + Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] ) - : [...existing, mergeChatResource(undefined, resourceUpdate)] + const key = `${resource.type}:${resource.id}` + const prev = existing.find((r) => `${r.type}:${r.id}` === key) + const next: ChatResource[] = prev + ? existing.map((r) => + `${r.type}:${r.id}` === key ? mergeChatResource(r, resourceUpdate) : r + ) + : [...existing, mergeChatResource(undefined, resourceUpdate)] + + await tx + .update(copilotChats) + .set({ resources: sql`${JSON.stringify(next)}::jsonb`, updatedAt: new Date() }) + .where(scope) + + return next + }) + ) - await db - .update(copilotChats) - .set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() }) - .where( - and( - eq(copilotChats.id, chatId), - eq(copilotChats.userId, userId), - isNull(copilotChats.deletedAt) - ) - ) + if (!merged) { + return createNotFoundResponse('Chat not found or unauthorized') + } logger.info('Added resource to chat', { chatId, resource }) @@ -122,41 +123,44 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => { if (!parsed.success) return parsed.response const { chatId, resources: newOrder } = parsed.data.body - const [chat] = await db - .select({ resources: copilotChats.resources }) - .from(copilotChats) - .where( - and( + const canonicalOrder = await serializeChatResourceWrite(chatId, () => + db.transaction(async (tx): Promise => { + const scope = and( eq(copilotChats.id, chatId), eq(copilotChats.userId, userId), isNull(copilotChats.deletedAt) ) - ) - .limit(1) + const [chat] = await tx + .select({ resources: copilotChats.resources }) + .from(copilotChats) + .where(scope) + .for('update') + .limit(1) - if (!chat) { - return createNotFoundResponse('Chat not found or unauthorized') - } + if (!chat) return undefined - const existing = sanitizeChatResources( - Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] + const existing = sanitizeChatResources( + Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] + ) + const next = reorderStoredChatResources(existing, newOrder) + if (!next) return null + + await tx + .update(copilotChats) + .set({ resources: sql`${JSON.stringify(next)}::jsonb`, updatedAt: new Date() }) + .where(scope) + + return next + }) ) - const canonicalOrder = reorderStoredChatResources(existing, newOrder) + + if (canonicalOrder === undefined) { + return createNotFoundResponse('Chat not found or unauthorized') + } if (!canonicalOrder) { return createBadRequestResponse('Reordered resources must match existing resources') } - await db - .update(copilotChats) - .set({ resources: sql`${JSON.stringify(canonicalOrder)}::jsonb`, updatedAt: new Date() }) - .where( - and( - eq(copilotChats.id, chatId), - eq(copilotChats.userId, userId), - isNull(copilotChats.deletedAt) - ) - ) - logger.info('Reordered resources for chat', { chatId, count: canonicalOrder.length }) return NextResponse.json({ success: true, resources: canonicalOrder }) @@ -185,39 +189,44 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => { if (!parsed.success) return parsed.response const { chatId, resourceType, resourceId } = parsed.data.body - // Old builds could persist an inner browser/terminal tab id. Closing the - // singleton panel removes every legacy row of that type so it cannot be - // canonicalized back into view on the next hydration. - const removePredicate = - resourceType === 'browser' || resourceType === 'terminal' - ? sql`elem->>'type' = ${resourceType}` - : sql`elem->>'type' = ${resourceType} AND elem->>'id' = ${resourceId}` - - const [updated] = await db - .update(copilotChats) - .set({ - resources: sql`COALESCE(( - SELECT jsonb_agg(elem) - FROM jsonb_array_elements(${copilotChats.resources}) elem - WHERE NOT (${removePredicate}) - ), '[]'::jsonb)`, - updatedAt: new Date(), - }) - .where( - and( + const merged = await serializeChatResourceWrite(chatId, () => + db.transaction(async (tx) => { + const scope = and( eq(copilotChats.id, chatId), eq(copilotChats.userId, userId), isNull(copilotChats.deletedAt) ) - ) - .returning({ resources: copilotChats.resources }) + const [chat] = await tx + .select({ resources: copilotChats.resources }) + .from(copilotChats) + .where(scope) + .for('update') + .limit(1) - if (!updated) { + if (!chat) return null + + const existing = sanitizeChatResources( + Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] + ) + const removeAllOfType = resourceType === 'browser' || resourceType === 'terminal' + const next = existing.filter( + (resource) => + resource.type !== resourceType || (!removeAllOfType && resource.id !== resourceId) + ) + + await tx + .update(copilotChats) + .set({ resources: sql`${JSON.stringify(next)}::jsonb`, updatedAt: new Date() }) + .where(scope) + + return next + }) + ) + + if (!merged) { return createNotFoundResponse('Chat not found or unauthorized') } - const merged = Array.isArray(updated.resources) ? (updated.resources as ChatResource[]) : [] - logger.info('Removed resource from chat', { chatId, resourceType, resourceId }) return NextResponse.json({ success: true, resources: merged }) 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 bf8ed4eccc4..ac54e8de4fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1848,26 +1848,23 @@ export function useChat( // Ephemeral panels were never persisted; nothing to delete server-side. if (isEphemeralResource({ type: resourceType, id: resourceId, title: '' })) return - const { inFlight: inFlightAdd, wasPending } = resourcePersistenceQueue.remove( - resourceType, - resourceId - ) - if (wasPending && !inFlightAdd) return + const { + inFlight: inFlightAdd, + scheduleDelete, + wasPending, + wasPersisted, + } = resourcePersistenceQueue.remove(resourceType, resourceId) + if (wasPending && !inFlightAdd && !wasPersisted) return const persistChatId = chatIdRef.current ?? selectedChatIdRef.current if (!persistChatId) return - const fireDelete = () => { + scheduleDelete(persistChatId, () => requestJson(removeMothershipChatResourceContract, { body: { chatId: persistChatId, resourceType, resourceId }, }).catch((err) => { logger.warn('Failed to persist resource removal', err) }) - } - if (inFlightAdd) { - inFlightAdd.finally(fireDelete) - } else { - fireDelete() - } + ) }, [resourcePersistenceQueue] ) @@ -2279,11 +2276,16 @@ export function useChat( useMothershipQueueStore.getState().migrate(pendingChatKey, resolvedChatId) } await Promise.allSettled( - pendingResources.map((resource) => - requestJson(addMothershipChatResourceContract, { - body: { chatId: resolvedChatId, resource }, + pendingResources.map((update) => { + const { clearViewId, ...resource } = update + return requestJson(addMothershipChatResourceContract, { + body: { + chatId: resolvedChatId, + resource, + ...(clearViewId === true ? { clearViewId: true as const } : {}), + }, }) - ) + }) ) queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(resolvedChatId), diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts index 2979721bf30..2e3b9eeca78 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts @@ -87,10 +87,16 @@ describe('resolveTableViewPinTransition', () => { ).toEqual({ nextViewId: 'view-pinned', pendingCreatedViewId: null }) }) - it('keeps the pending creation when the pin is already represented locally', () => { + it('clears a different pending creation when the pin is already represented in the URL', () => { expect( resolveTableViewPinTransition('view-pinned', 'view-created', 'view-pinned', 'view-created') - ).toEqual({ nextViewId: null, pendingCreatedViewId: 'view-created' }) + ).toEqual({ nextViewId: null, pendingCreatedViewId: null }) + }) + + it('keeps a pending creation when it created the pinned view', () => { + expect( + resolveTableViewPinTransition('view-pinned', 'view-pinned', 'view-pinned', 'view-pinned') + ).toEqual({ nextViewId: null, pendingCreatedViewId: 'view-pinned' }) }) it('replaces a different active URL even if the pin was applied previously', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts index c702e6e215b..21cebfcdcb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts @@ -65,7 +65,10 @@ export function resolveTableViewPinTransition( pendingCreatedViewId: string | null ): TableViewPinTransition { if (activeViewId === pinnedViewId || (activeViewId === null && appliedViewId === pinnedViewId)) { - return { nextViewId: null, pendingCreatedViewId } + return { + nextViewId: null, + pendingCreatedViewId: pendingCreatedViewId === pinnedViewId ? pendingCreatedViewId : null, + } } return { nextViewId: pinnedViewId, pendingCreatedViewId: null } } diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 5ecf11037b0..58f81aead61 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -90,15 +90,21 @@ export type CreateWorkflowCopilotChatBody = z.input { + if (resource.viewId === undefined || resource.type === 'table') return + ctx.addIssue({ + code: 'custom', + path: ['viewId'], + message: 'viewId is only valid for table resources', + }) + }) export const addCopilotChatResourceBodySchema = z .object({ diff --git a/apps/sim/lib/api/contracts/mothership-chats.test.ts b/apps/sim/lib/api/contracts/mothership-chats.test.ts index c2fad96fa1b..69d4871ee9c 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.test.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.test.ts @@ -49,4 +49,13 @@ describe('addMothershipChatResourceBodySchema', () => { }).success ).toBe(false) }) + + it('rejects a saved-view pin for a non-table resource', () => { + expect( + addMothershipChatResourceBodySchema.safeParse({ + chatId: 'chat-1', + resource: { type: 'file', id: 'file-1', title: 'Accounts.csv', viewId: 'view-1' }, + }).success + ).toBe(false) + }) }) diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts index 9e6e6e02d8e..51de85d47cc 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts @@ -78,17 +78,81 @@ describe('ResourcePersistenceQueue', () => { .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() .mockReturnValueOnce(stale.promise) .mockReturnValueOnce(fresh.promise) + const remove = vi.fn().mockResolvedValue({ success: true }) const queue = new ResourcePersistenceQueue({ persist, onError }) queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1') - queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + removal.scheduleDelete('chat-1', remove) queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') - stale.resolve({ success: true }) await Promise.resolve() + expect(persist).toHaveBeenCalledTimes(1) + expect(remove).not.toHaveBeenCalled() + stale.resolve({ success: true }) + await vi.waitFor(() => expect(persist).toHaveBeenCalledTimes(2)) + expect(queue.inFlight.size).toBe(1) fresh.resolve({ success: true }) await Promise.allSettled(Array.from(queue.inFlight.values())) expect(queue.inFlight.size).toBe(0) }) + + it('deletes a stored resource after its pending update fails', async () => { + const failed = deferred() + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockReturnValueOnce(failed.promise) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', TABLE_RESOURCE) + failed.reject(new Error('offline')) + await Promise.allSettled(Array.from(queue.inFlight.values())) + + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + expect(removal.wasPending).toBe(true) + expect(removal.wasPersisted).toBe(true) + const remove = vi.fn().mockResolvedValue({ success: true }) + removal.scheduleDelete('chat-1', remove) + await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) + }) + + it('skips deletion after an initial add fails', async () => { + const failed = deferred() + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockReturnValueOnce(failed.promise) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue(TABLE_RESOURCE, 'chat-1') + failed.reject(new Error('offline')) + await Promise.allSettled(Array.from(queue.inFlight.values())) + + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + expect(removal.wasPending).toBe(true) + expect(removal.wasPersisted).toBe(false) + }) + + it('persists a re-add after an already-started deletion settles', async () => { + const deletion = deferred() + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockResolvedValue({ success: true }) + const remove = vi.fn().mockReturnValue(deletion.promise) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', TABLE_RESOURCE) + await Promise.allSettled(Array.from(queue.inFlight.values())) + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + removal.scheduleDelete('chat-1', remove) + await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') + await Promise.resolve() + expect(persist).toHaveBeenCalledOnce() + + deletion.resolve({ success: true }) + await vi.waitFor(() => expect(persist).toHaveBeenCalledTimes(2)) + expect(persist.mock.calls[1]).toEqual(['chat-1', { ...TABLE_RESOURCE, viewId: 'view-b' }]) + }) }) diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.ts index aaedcb5e4aa..03e14063ff7 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.ts @@ -8,6 +8,8 @@ interface ResourcePersistenceQueueOptions { export interface RemovedResourcePersistence { inFlight: Promise | undefined + scheduleDelete: (chatId: string, remove: () => Promise) => void + wasPersisted: boolean wasPending: boolean } @@ -23,6 +25,8 @@ export class ResourcePersistenceQueue { private readonly desiredUpdates = new Map() private readonly failedKeys = new Set() + private readonly persistedKeys = new Set() + private readonly removalTokens = new Map() private readonly writeTokens = new Map() private readonly persist: ResourcePersistenceQueueOptions['persist'] private readonly onError: ResourcePersistenceQueueOptions['onError'] @@ -38,6 +42,10 @@ export class ResourcePersistenceQueue { base?: MothershipResource ): void { const key = this.getKey(update) + const trackedLocally = + this.desiredUpdates.has(key) || this.pendingKeys.has(key) || this.inFlight.has(key) + if (base && !trackedLocally) this.persistedKeys.add(key) + this.removalTokens.delete(key) const previous = this.desiredUpdates.get(key) ?? base this.desiredUpdates.set(key, mergePendingChatResourceUpdate(previous, update)) this.failedKeys.delete(key) @@ -63,11 +71,24 @@ export class ResourcePersistenceQueue { const key = `${type}:${id}` const wasPending = this.pendingKeys.delete(key) const inFlight = this.inFlight.get(key) - this.inFlight.delete(key) - this.writeTokens.delete(key) + const wasPersisted = this.persistedKeys.delete(key) + const removalToken = Symbol(key) + this.removalTokens.set(key, removalToken) this.desiredUpdates.delete(key) this.failedKeys.delete(key) - return { inFlight, wasPending } + return { + inFlight, + scheduleDelete: (chatId, remove) => { + const startRemoval = () => this.startRemoval(key, chatId, removalToken, remove) + if (inFlight) { + void inFlight.then(startRemoval, startRemoval) + return + } + startRemoval() + }, + wasPending, + wasPersisted, + } } getPendingUpdates(): MothershipResourceUpdate[] { @@ -82,6 +103,8 @@ export class ResourcePersistenceQueue { this.inFlight.clear() this.desiredUpdates.clear() this.failedKeys.clear() + this.persistedKeys.clear() + this.removalTokens.clear() this.writeTokens.clear() } @@ -91,6 +114,35 @@ export class ResourcePersistenceQueue { } } + private startRemoval( + key: string, + chatId: string, + removalToken: symbol, + remove: () => Promise + ): void { + if (this.removalTokens.get(key) !== removalToken) return + + const writeToken = Symbol(key) + const tracked = Promise.resolve() + .then(() => { + if (this.removalTokens.get(key) !== removalToken) return + return remove() + }) + .catch(this.onError) + .finally(() => { + if (this.writeTokens.get(key) !== writeToken) return + this.writeTokens.delete(key) + this.inFlight.delete(key) + if (this.removalTokens.get(key) === removalToken) { + this.removalTokens.delete(key) + this.persistedKeys.delete(key) + } + if (this.pendingKeys.has(key)) this.start(key, chatId) + }) + this.writeTokens.set(key, writeToken) + this.inFlight.set(key, tracked) + } + private start(key: string, chatId: string): void { const update = this.desiredUpdates.get(key) if (!update) { @@ -105,10 +157,12 @@ export class ResourcePersistenceQueue { .then(() => this.persist(chatId, update)) .then((result) => { succeeded = true + this.persistedKeys.add(key) return result }) .catch((error) => { if (this.writeTokens.get(key) !== token) return + if (!this.desiredUpdates.has(key)) return this.pendingKeys.add(key) this.failedKeys.add(key) this.onError(error) diff --git a/apps/sim/lib/copilot/resources/persistence.test.ts b/apps/sim/lib/copilot/resources/persistence.test.ts index 799696b69f3..76266af9fb2 100644 --- a/apps/sim/lib/copilot/resources/persistence.test.ts +++ b/apps/sim/lib/copilot/resources/persistence.test.ts @@ -3,7 +3,10 @@ */ import { databaseMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { persistChatResources } from '@/lib/copilot/resources/persistence' +import { + persistChatResources, + serializeChatResourceWrite, +} from '@/lib/copilot/resources/persistence' function deferred() { let resolve: () => void = () => {} @@ -45,4 +48,17 @@ describe('persistChatResources ordering', () => { second.resolve() await Promise.all([firstWrite, secondWrite]) }) + + it('serializes tool writes behind other resource mutations for the same chat', async () => { + const apiMutation = deferred() + const firstWrite = serializeChatResourceWrite('chat-1', () => apiMutation.promise) + const secondWrite = persistChatResources('chat-1', [TABLE_RESOURCE]) + + await Promise.resolve() + expect(transaction).not.toHaveBeenCalled() + apiMutation.resolve() + await Promise.all([firstWrite, secondWrite]) + + expect(transaction).toHaveBeenCalledOnce() + }) }) diff --git a/apps/sim/lib/copilot/resources/persistence.ts b/apps/sim/lib/copilot/resources/persistence.ts index 7bba594269b..c4f7230b136 100644 --- a/apps/sim/lib/copilot/resources/persistence.ts +++ b/apps/sim/lib/copilot/resources/persistence.ts @@ -25,17 +25,17 @@ const logger = createLogger('CopilotResources') type ChatResource = MothershipResource -const chatResourceWriteChain = new Map>() +const chatResourceWriteChain = new Map>() -async function serializeChatResourceWrite( +export async function serializeChatResourceWrite( chatId: string, - write: () => Promise -): Promise { + write: () => Promise +): Promise { const tail = chatResourceWriteChain.get(chatId) ?? Promise.resolve() const run = tail.catch(() => {}).then(write) chatResourceWriteChain.set(chatId, run) try { - await run + return await run } finally { if (chatResourceWriteChain.get(chatId) === run) chatResourceWriteChain.delete(chatId) } From 905fc86e9c9e4edca842685aa5648cacfd559ffa Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:35:38 -0700 Subject: [PATCH 13/17] fix(copilot): retain resource removal intent --- .../[workspaceId]/home/hooks/use-chat.ts | 4 +-- .../[workspaceId]/tables/[tableId]/table.tsx | 2 +- .../client-persistence-queue.test.ts | 22 ++++++++++++ .../resources/client-persistence-queue.ts | 35 +++++++++++++++---- 4 files changed, 53 insertions(+), 10 deletions(-) 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 ac54e8de4fa..cb064917f18 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1815,7 +1815,7 @@ export function useChat( (r) => r.type === resourceUpdate.type && r.id === resourceUpdate.id ) const resource = mergeChatResource(existing, resourceUpdate) - if (existing && resource === existing) { + if (existing && resource === existing && resourceUpdate.clearViewId !== true) { return false } @@ -1861,8 +1861,6 @@ export function useChat( scheduleDelete(persistChatId, () => requestJson(removeMothershipChatResourceContract, { body: { chatId: persistChatId, resourceType, resourceId }, - }).catch((err) => { - logger.warn('Failed to persist resource removal', err) }) ) }, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 392610192ad..f2af3da7751 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -726,8 +726,8 @@ export function Table({ viewPin.viewId, pendingCreatedViewIdRef.current ) - if (!transition.nextViewId) return pendingCreatedViewIdRef.current = transition.pendingCreatedViewId + if (!transition.nextViewId) return preservedViewStateRef.current = null setTableParams({ view: transition.nextViewId }) }, [embedded, viewPin, views, activeViewId, tableId, consumeViewPin, setTableParams]) diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts index 51de85d47cc..d88ab18b21d 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts @@ -155,4 +155,26 @@ describe('ResourcePersistenceQueue', () => { await vi.waitFor(() => expect(persist).toHaveBeenCalledTimes(2)) expect(persist.mock.calls[1]).toEqual(['chat-1', { ...TABLE_RESOURCE, viewId: 'view-b' }]) }) + + it('retries a failed deletion on the next flush', async () => { + const first = deferred() + const persist = vi.fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + const remove = vi + .fn<() => Promise>() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ success: true }) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + removal.scheduleDelete('chat-1', remove) + first.reject(new Error('offline')) + await Promise.allSettled(Array.from(queue.inFlight.values())) + + expect(queue.pendingKeys.has(`${TABLE_RESOURCE.type}:${TABLE_RESOURCE.id}`)).toBe(true) + expect(onError).toHaveBeenCalledOnce() + await queue.flush('chat-1') + + expect(remove).toHaveBeenCalledTimes(2) + expect(queue.pendingKeys.size).toBe(0) + }) }) diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.ts index 03e14063ff7..5ac625de3b3 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.ts @@ -25,6 +25,7 @@ export class ResourcePersistenceQueue { private readonly desiredUpdates = new Map() private readonly failedKeys = new Set() + private readonly pendingRemovals = new Map Promise>() private readonly persistedKeys = new Set() private readonly removalTokens = new Map() private readonly writeTokens = new Map() @@ -45,6 +46,7 @@ export class ResourcePersistenceQueue { const trackedLocally = this.desiredUpdates.has(key) || this.pendingKeys.has(key) || this.inFlight.has(key) if (base && !trackedLocally) this.persistedKeys.add(key) + this.pendingRemovals.delete(key) this.removalTokens.delete(key) const previous = this.desiredUpdates.get(key) ?? base this.desiredUpdates.set(key, mergePendingChatResourceUpdate(previous, update)) @@ -73,12 +75,14 @@ export class ResourcePersistenceQueue { const inFlight = this.inFlight.get(key) const wasPersisted = this.persistedKeys.delete(key) const removalToken = Symbol(key) + this.pendingRemovals.delete(key) this.removalTokens.set(key, removalToken) this.desiredUpdates.delete(key) this.failedKeys.delete(key) return { inFlight, scheduleDelete: (chatId, remove) => { + this.pendingRemovals.set(key, remove) const startRemoval = () => this.startRemoval(key, chatId, removalToken, remove) if (inFlight) { void inFlight.then(startRemoval, startRemoval) @@ -103,6 +107,7 @@ export class ResourcePersistenceQueue { this.inFlight.clear() this.desiredUpdates.clear() this.failedKeys.clear() + this.pendingRemovals.clear() this.persistedKeys.clear() this.removalTokens.clear() this.writeTokens.clear() @@ -110,7 +115,14 @@ export class ResourcePersistenceQueue { private startPending(chatId: string): void { for (const key of this.pendingKeys) { - if (!this.failedKeys.has(key) && !this.inFlight.has(key)) this.start(key, chatId) + if (this.failedKeys.has(key) || this.inFlight.has(key)) continue + const pendingRemoval = this.pendingRemovals.get(key) + const removalToken = this.removalTokens.get(key) + if (pendingRemoval && removalToken) { + this.startRemoval(key, chatId, removalToken, pendingRemoval) + continue + } + this.start(key, chatId) } } @@ -122,22 +134,33 @@ export class ResourcePersistenceQueue { ): void { if (this.removalTokens.get(key) !== removalToken) return + this.pendingKeys.delete(key) + let succeeded = false const writeToken = Symbol(key) const tracked = Promise.resolve() - .then(() => { + .then(async () => { if (this.removalTokens.get(key) !== removalToken) return - return remove() + await remove() + succeeded = true + }) + .catch((error) => { + if (this.removalTokens.get(key) !== removalToken) return + this.pendingKeys.add(key) + this.failedKeys.add(key) + this.onError(error) }) - .catch(this.onError) .finally(() => { if (this.writeTokens.get(key) !== writeToken) return this.writeTokens.delete(key) this.inFlight.delete(key) - if (this.removalTokens.get(key) === removalToken) { + if (succeeded && this.removalTokens.get(key) === removalToken) { + this.pendingRemovals.delete(key) this.removalTokens.delete(key) this.persistedKeys.delete(key) } - if (this.pendingKeys.has(key)) this.start(key, chatId) + if (this.removalTokens.get(key) !== removalToken && this.pendingKeys.has(key)) { + this.start(key, chatId) + } }) this.writeTokens.set(key, writeToken) this.inFlight.set(key, tracked) From ba3521988c570a5a57abaddc0c4bc8b173609d01 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:05:07 -0700 Subject: [PATCH 14/17] fix: isolate copilot resource persistence by chat --- .../[workspaceId]/home/hooks/use-chat.ts | 171 +++++++++--------- .../client-persistence-queue.test.ts | 79 +++++++- .../resources/client-persistence-queue.ts | 91 ++++++++-- 3 files changed, 230 insertions(+), 111 deletions(-) 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 cb064917f18..7e8e1f7741f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1404,9 +1404,8 @@ export function useChat( }) } const resourcePersistenceQueue = resourcePersistenceQueueRef.current - const pendingPersistResourceKeysRef = useRef(resourcePersistenceQueue.pendingKeys) - const inFlightResourceAddsRef = useRef(resourcePersistenceQueue.inFlight) - const reorderNeededAfterFlushRef = useRef(false) + const pendingResourceReordersRef = useRef(new Map()) + const pendingResourceReorderFlushesRef = useRef(new Map>()) // Derive the effective active resource ID for rendering without writing a // passive fallback back into the user's URL selection. @@ -1663,8 +1662,6 @@ export function useChat( // Pending view pins belong to the chat whose stream issued them. useTableViewPinStore.getState().reset() undisplayableResourcesRef.current = [] - resourcePersistenceQueue.clear() - reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() // Editing binds to this hook's composer — release it before rotating chatKey. useMothershipQueueStore.getState().setEditing(chatKeyRef.current, null) @@ -1691,30 +1688,59 @@ export function useChat( workspaceId, ]) - const flushPendingResources = useCallback( - async (chatId: string) => { - if (pendingPersistResourceKeysRef.current.size === 0) return - await resourcePersistenceQueue.flush(chatId) - if (!reorderNeededAfterFlushRef.current) return - reorderNeededAfterFlushRef.current = false - const localOrder = [ - ...resourcesRef.current.filter( - (r) => - r.id !== 'streaming-file' && - !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`) - ), - ...undisplayableResourcesRef.current, - ] - if (localOrder.length === 0) return - requestJson(reorderMothershipChatResourcesContract, { - body: { chatId, resources: localOrder }, - }).catch((err) => { - logger.warn('Failed to sync resource order after flush', err) + const flushPendingResourceReorder = useCallback( + (chatId: string): Promise => { + const activeFlush = pendingResourceReorderFlushesRef.current.get(chatId) + if (activeFlush) return activeFlush + + const flush = async () => { + while (true) { + const pendingOrder = pendingResourceReordersRef.current.get(chatId) + if (!pendingOrder) return + if (resourcePersistenceQueue.getPendingResourceKeys(chatId).size > 0) return + + const inFlightWrites = resourcePersistenceQueue.getInFlightWrites(chatId) + if (inFlightWrites.length > 0) { + await Promise.allSettled(inFlightWrites) + continue + } + + pendingResourceReordersRef.current.delete(chatId) + if (pendingOrder.length === 0) return + try { + await requestJson(reorderMothershipChatResourcesContract, { + body: { chatId, resources: pendingOrder }, + }) + } catch (error) { + if (!pendingResourceReordersRef.current.has(chatId)) { + pendingResourceReordersRef.current.set(chatId, pendingOrder) + } + logger.warn('Failed to persist resource reorder; will retry on next hydration', error) + return + } + } + } + const tracked = flush().finally(() => { + if (pendingResourceReorderFlushesRef.current.get(chatId) === tracked) { + pendingResourceReorderFlushesRef.current.delete(chatId) + } }) + pendingResourceReorderFlushesRef.current.set(chatId, tracked) + return tracked }, [resourcePersistenceQueue] ) + const flushPendingResources = useCallback( + async (chatId: string, sourceScopeId: string = chatId) => { + if (resourcePersistenceQueue.getPendingResourceKeys(sourceScopeId).size > 0) { + await resourcePersistenceQueue.flush(chatId, sourceScopeId) + } + await flushPendingResourceReorder(chatId) + }, + [flushPendingResourceReorder, resourcePersistenceQueue] + ) + const adoptResolvedChatId = useCallback( (chatId: string, options?: { replaceHomeHistory?: boolean; invalidateList?: boolean }) => { const selectedChatId = selectedChatIdRef.current @@ -1790,7 +1816,7 @@ export function useChat( if (options?.invalidateList) { queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) }) } - flushPendingResources(chatId) + flushPendingResources(chatId, pendingChatKey) }, [flushPendingResources, queryClient, workspaceId] ) @@ -1834,7 +1860,8 @@ export function useChat( } const persistChatId = chatIdRef.current ?? selectedChatIdRef.current - resourcePersistenceQueue.enqueue(resourceUpdate, persistChatId, existing) + const persistenceScopeId = persistChatId ?? pendingChatKeyRef.current + resourcePersistenceQueue.enqueue(resourceUpdate, persistChatId, existing, persistenceScopeId) return existing === undefined }, [resourcePersistenceQueue] @@ -1848,15 +1875,24 @@ export function useChat( // Ephemeral panels were never persisted; nothing to delete server-side. if (isEphemeralResource({ type: resourceType, id: resourceId, title: '' })) return + const existing = resourcesRef.current.find( + (resource) => resource.type === resourceType && resource.id === resourceId + ) + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + const persistenceScopeId = persistChatId ?? pendingChatKeyRef.current const { inFlight: inFlightAdd, scheduleDelete, wasPending, wasPersisted, - } = resourcePersistenceQueue.remove(resourceType, resourceId) + } = resourcePersistenceQueue.remove( + resourceType, + resourceId, + persistenceScopeId, + Boolean(existing && persistChatId) + ) if (wasPending && !inFlightAdd && !wasPersisted) return - const persistChatId = chatIdRef.current ?? selectedChatIdRef.current if (!persistChatId) return scheduleDelete(persistChatId, () => requestJson(removeMothershipChatResourceContract, { @@ -1898,53 +1934,20 @@ export function useChat( [workspaceId, removeResource] ) - const reorderResources = useCallback((newOrder: MothershipResource[]) => { - setResources(newOrder) - const persistChatId = chatIdRef.current ?? selectedChatIdRef.current - if (!persistChatId) return - const pendingKeys = pendingPersistResourceKeysRef.current - const inFlightAdds = inFlightResourceAddsRef.current - const hasUnsyncedAdds = newOrder.some((r) => { - const key = `${r.type}:${r.id}` - return pendingKeys.has(key) || inFlightAdds.has(key) - }) - if (hasUnsyncedAdds) { - reorderNeededAfterFlushRef.current = true - if (pendingKeys.size === 0 && inFlightAdds.size > 0) { - Promise.allSettled(Array.from(inFlightAdds.values())).then(() => { - if (!reorderNeededAfterFlushRef.current) return - reorderNeededAfterFlushRef.current = false - const chatId = chatIdRef.current ?? selectedChatIdRef.current - if (!chatId) return - const order = [ - ...resourcesRef.current.filter( - (r) => - !isEphemeralResource(r) && - !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`) - ), - ...undisplayableResourcesRef.current, - ] - if (order.length === 0) return - requestJson(reorderMothershipChatResourcesContract, { - body: { chatId, resources: order }, - }).catch((err) => { - logger.warn('Failed to sync resource order after in-flight ADDs', err) - }) - }) - } - return - } - const persistableResources = [ - ...newOrder.filter((r) => !isEphemeralResource(r)), - ...undisplayableResourcesRef.current, - ] - if (persistableResources.length === 0) return - requestJson(reorderMothershipChatResourcesContract, { - body: { chatId: persistChatId, resources: persistableResources }, - }).catch((err) => { - logger.warn('Failed to persist resource reorder', err) - }) - }, []) + const reorderResources = useCallback( + (newOrder: MothershipResource[]) => { + setResources(newOrder) + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + if (!persistChatId) return + const persistableResources = [ + ...newOrder.filter((resource) => !isEphemeralResource(resource)), + ...undisplayableResourcesRef.current, + ] + pendingResourceReordersRef.current.set(persistChatId, persistableResources) + void flushPendingResourceReorder(persistChatId) + }, + [flushPendingResourceReorder] + ) const ensureWorkflowToolResource = useCallback( (toolArgs: Record): string | undefined => { @@ -2228,7 +2231,8 @@ export function useChat( const streamOwnerId = chatIdRef.current const pendingTurn = activeTurnRef.current const pendingStreamId = streamIdRef.current ?? pendingTurn?.userMessageId - const pendingResources = resourcePersistenceQueue.getPendingUpdates() + const pendingResourceScopeId = + streamOwnerId ?? pendingTurn?.pendingChatKey ?? pendingChatKeyRef.current const navigatedToDifferentChat = sendingRef.current && initialChatId !== streamOwnerId && @@ -2273,18 +2277,7 @@ export function useChat( if (pendingChatKey) { useMothershipQueueStore.getState().migrate(pendingChatKey, resolvedChatId) } - await Promise.allSettled( - pendingResources.map((update) => { - const { clearViewId, ...resource } = update - return requestJson(addMothershipChatResourceContract, { - body: { - chatId: resolvedChatId, - resource, - ...(clearViewId === true ? { clearViewId: true as const } : {}), - }, - }) - }) - ) + await resourcePersistenceQueue.flush(resolvedChatId, pendingResourceScopeId) queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(resolvedChatId), }) @@ -2331,8 +2324,6 @@ export function useChat( setResources([]) setActiveResourceId(null) useTableViewPinStore.getState().reset() - resourcePersistenceQueue.clear() - reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() // Rotate the bucket key; the previous chat's queue stays in the store. // Release editing on the chat we're leaving (composer-scoped). diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts index d88ab18b21d..3ef88622cff 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts @@ -64,7 +64,7 @@ describe('ResourcePersistenceQueue', () => { first.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) - expect(queue.getPendingUpdates()).toEqual([{ ...TABLE_RESOURCE, clearViewId: true }]) + expect(queue.getPendingUpdates('chat-1')).toEqual([{ ...TABLE_RESOURCE, clearViewId: true }]) await queue.flush('chat-1') expect(persist.mock.calls[1]).toEqual(['chat-1', { ...TABLE_RESOURCE, clearViewId: true }]) @@ -82,7 +82,7 @@ describe('ResourcePersistenceQueue', () => { const queue = new ResourcePersistenceQueue({ persist, onError }) queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1') - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') removal.scheduleDelete('chat-1', remove) queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') await Promise.resolve() @@ -92,6 +92,7 @@ describe('ResourcePersistenceQueue', () => { stale.resolve({ success: true }) await vi.waitFor(() => expect(persist).toHaveBeenCalledTimes(2)) + expect(remove).not.toHaveBeenCalled() expect(queue.inFlight.size).toBe(1) fresh.resolve({ success: true }) await Promise.allSettled(Array.from(queue.inFlight.values())) @@ -109,7 +110,7 @@ describe('ResourcePersistenceQueue', () => { failed.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') expect(removal.wasPending).toBe(true) expect(removal.wasPersisted).toBe(true) const remove = vi.fn().mockResolvedValue({ success: true }) @@ -128,7 +129,7 @@ describe('ResourcePersistenceQueue', () => { failed.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') expect(removal.wasPending).toBe(true) expect(removal.wasPersisted).toBe(false) }) @@ -143,7 +144,7 @@ describe('ResourcePersistenceQueue', () => { queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', TABLE_RESOURCE) await Promise.allSettled(Array.from(queue.inFlight.values())) - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') removal.scheduleDelete('chat-1', remove) await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) @@ -165,16 +166,80 @@ describe('ResourcePersistenceQueue', () => { .mockResolvedValueOnce({ success: true }) const queue = new ResourcePersistenceQueue({ persist, onError }) - const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id) + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') removal.scheduleDelete('chat-1', remove) first.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) - expect(queue.pendingKeys.has(`${TABLE_RESOURCE.type}:${TABLE_RESOURCE.id}`)).toBe(true) + expect( + queue.getPendingResourceKeys('chat-1').has(`${TABLE_RESOURCE.type}:${TABLE_RESOURCE.id}`) + ).toBe(true) expect(onError).toHaveBeenCalledOnce() await queue.flush('chat-1') expect(remove).toHaveBeenCalledTimes(2) expect(queue.pendingKeys.size).toBe(0) }) + + it('keeps failed writes isolated by chat until that chat is flushed again', async () => { + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue({ success: true }) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1') + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-2') + await vi.waitFor(() => expect(persist).toHaveBeenCalledTimes(2)) + + expect(persist.mock.calls[1]).toEqual(['chat-2', { ...TABLE_RESOURCE, viewId: 'view-b' }]) + expect(queue.getPendingResourceKeys('chat-1')).toEqual(new Set(['table:table-1'])) + expect(queue.getPendingResourceKeys('chat-2')).toEqual(new Set()) + + await queue.flush('chat-1') + + expect(persist.mock.calls[2]).toEqual(['chat-1', { ...TABLE_RESOURCE, viewId: 'view-a' }]) + expect(queue.getPendingResourceKeys('chat-1')).toEqual(new Set()) + }) + + it('adopts provisional writes when a new chat receives its durable id', async () => { + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockResolvedValue({ success: true }) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, undefined, undefined, 'pending-chat-1') + + expect(queue.getPendingResourceKeys('pending-chat-1')).toEqual(new Set(['table:table-1'])) + await queue.flush('chat-1', 'pending-chat-1') + + expect(persist).toHaveBeenCalledWith('chat-1', { ...TABLE_RESOURCE, viewId: 'view-a' }) + expect(queue.getPendingResourceKeys('pending-chat-1')).toEqual(new Set()) + expect(queue.getPendingResourceKeys('chat-1')).toEqual(new Set()) + }) + + it('remembers a stored resource until a failed deletion eventually succeeds', async () => { + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockResolvedValueOnce({ success: true }) + .mockRejectedValueOnce(new Error('re-add failed')) + const remove = vi.fn().mockRejectedValueOnce(new Error('delete failed')) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', TABLE_RESOURCE) + await Promise.allSettled(queue.getInFlightWrites('chat-1')) + + const firstRemoval = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + firstRemoval.scheduleDelete('chat-1', remove) + await Promise.allSettled(queue.getInFlightWrites('chat-1')) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') + await Promise.allSettled(queue.getInFlightWrites('chat-1')) + + const secondRemoval = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + expect(secondRemoval.wasPending).toBe(true) + expect(secondRemoval.wasPersisted).toBe(true) + }) }) diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.ts index 5ac625de3b3..2a989756409 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.ts @@ -6,6 +6,9 @@ interface ResourcePersistenceQueueOptions { onError: (error: unknown) => void } +const UNSCOPED_QUEUE_ID = 'unscoped' +const QUEUE_KEY_SEPARATOR = '\u0000' + export interface RemovedResourcePersistence { inFlight: Promise | undefined scheduleDelete: (chatId: string, remove: () => Promise) => void @@ -40,9 +43,10 @@ export class ResourcePersistenceQueue { enqueue( update: MothershipResourceUpdate, chatId: string | undefined, - base?: MothershipResource + base?: MothershipResource, + scopeId: string | undefined = chatId ): void { - const key = this.getKey(update) + const key = this.getKey(scopeId, update.type, update.id) const trackedLocally = this.desiredUpdates.has(key) || this.pendingKeys.has(key) || this.inFlight.has(key) if (base && !trackedLocally) this.persistedKeys.add(key) @@ -60,20 +64,31 @@ export class ResourcePersistenceQueue { this.start(key, chatId) } - async flush(chatId: string): Promise { - for (const key of this.pendingKeys) this.failedKeys.delete(key) + async flush(chatId: string, sourceScopeId: string = chatId): Promise { + this.adoptScope(sourceScopeId, chatId) + for (const key of this.getScopedKeys(this.pendingKeys, chatId)) this.failedKeys.delete(key) this.startPending(chatId) - while (this.inFlight.size > 0) { - await Promise.allSettled(Array.from(this.inFlight.values())) + while (true) { + const inFlight = this.getInFlightWrites(chatId) + if (inFlight.length === 0) return + await Promise.allSettled(inFlight) } } - remove(type: string, id: string): RemovedResourcePersistence { - const key = `${type}:${id}` + remove( + type: string, + id: string, + scopeId: string | undefined, + assumePersisted = false + ): RemovedResourcePersistence { + const key = this.getKey(scopeId, type, id) + const trackedLocally = + this.desiredUpdates.has(key) || this.pendingKeys.has(key) || this.inFlight.has(key) + if (assumePersisted && !trackedLocally) this.persistedKeys.add(key) const wasPending = this.pendingKeys.delete(key) const inFlight = this.inFlight.get(key) - const wasPersisted = this.persistedKeys.delete(key) + const wasPersisted = this.persistedKeys.has(key) const removalToken = Symbol(key) this.pendingRemovals.delete(key) this.removalTokens.set(key, removalToken) @@ -95,13 +110,27 @@ export class ResourcePersistenceQueue { } } - getPendingUpdates(): MothershipResourceUpdate[] { - return Array.from(this.pendingKeys).flatMap((key) => { + getPendingUpdates(scopeId?: string): MothershipResourceUpdate[] { + return this.getScopedKeys(this.pendingKeys, scopeId).flatMap((key) => { const update = this.desiredUpdates.get(key) return update ? [update] : [] }) } + getPendingResourceKeys(scopeId?: string): Set { + const prefix = this.getScopePrefix(scopeId) + return new Set( + this.getScopedKeys(this.pendingKeys, scopeId).map((key) => key.slice(prefix.length)) + ) + } + + getInFlightWrites(scopeId?: string): Promise[] { + return this.getScopedKeys(this.inFlight, scopeId).flatMap((key) => { + const write = this.inFlight.get(key) + return write ? [write] : [] + }) + } + clear(): void { this.pendingKeys.clear() this.inFlight.clear() @@ -114,7 +143,7 @@ export class ResourcePersistenceQueue { } private startPending(chatId: string): void { - for (const key of this.pendingKeys) { + for (const key of this.getScopedKeys(this.pendingKeys, chatId)) { if (this.failedKeys.has(key) || this.inFlight.has(key)) continue const pendingRemoval = this.pendingRemovals.get(key) const removalToken = this.removalTokens.get(key) @@ -205,7 +234,41 @@ export class ResourcePersistenceQueue { this.inFlight.set(key, tracked) } - private getKey(resource: Pick): string { - return `${resource.type}:${resource.id}` + private adoptScope(sourceScopeId: string, targetScopeId: string): void { + if (sourceScopeId === targetScopeId) return + const sourcePrefix = this.getScopePrefix(sourceScopeId) + for (const sourceKey of this.getScopedKeys(this.pendingKeys, sourceScopeId)) { + const resourceKey = sourceKey.slice(sourcePrefix.length) + const targetKey = `${this.getScopePrefix(targetScopeId)}${resourceKey}` + const sourceUpdate = this.desiredUpdates.get(sourceKey) + const targetUpdate = this.desiredUpdates.get(targetKey) + if (sourceUpdate) { + this.desiredUpdates.set( + targetKey, + targetUpdate ? mergePendingChatResourceUpdate(targetUpdate, sourceUpdate) : sourceUpdate + ) + } + this.desiredUpdates.delete(sourceKey) + this.pendingKeys.delete(sourceKey) + this.pendingKeys.add(targetKey) + if (this.failedKeys.delete(sourceKey)) this.failedKeys.add(targetKey) + if (this.persistedKeys.delete(sourceKey)) this.persistedKeys.add(targetKey) + } + } + + private getScopedKeys( + collection: ReadonlySet | ReadonlyMap, + scopeId?: string + ): string[] { + const prefix = this.getScopePrefix(scopeId) + return Array.from(collection.keys()).filter((key) => key.startsWith(prefix)) + } + + private getScopePrefix(scopeId?: string): string { + return `${scopeId ?? UNSCOPED_QUEUE_ID}${QUEUE_KEY_SEPARATOR}` + } + + private getKey(scopeId: string | undefined, type: string, id: string): string { + return `${this.getScopePrefix(scopeId)}${type}:${id}` } } From 6c6da57a2dc9417174be8f1426fb46d110def825 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:40:30 -0700 Subject: [PATCH 15/17] fix(tables): restore view when returning to chat --- .../resource-content.test.tsx | 70 ++++++++++++++++++ .../resource-content/resource-content.tsx | 20 ++++++ .../home/hooks/use-chat.mount-send.test.tsx | 72 ++++++++++++++++++- .../[workspaceId]/home/hooks/use-chat.ts | 45 +++++++++--- .../[workspaceId]/tables/[tableId]/table.tsx | 15 ++-- .../tables/[tableId]/view-state.test.ts | 20 ++++++ .../tables/[tableId]/view-state.ts | 17 +++-- 7 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.test.tsx new file mode 100644 index 00000000000..d6d37c9e4ed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.test.tsx @@ -0,0 +1,70 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/table', () => ({ + Table: () => null, +})) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session', + () => ({ BrowserSession: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session', + () => ({ TerminalSession: () => null }) +) + +import { ResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { useTableViewPinStore } from '@/stores/table/view-pin/store' + +describe('ResourceContent table view handoff', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + useTableViewPinStore.getState().reset() + container = document.createElement('div') + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + useTableViewPinStore.getState().reset() + }) + + function render(resource: MothershipResource) { + act(() => { + root.render( + ( + + ) as ReactNode + ) + }) + } + + it('hands off a saved view that arrives after the embedded table mounts', () => { + const table: MothershipResource = { + type: 'table', + id: 'table-1', + title: 'Invoices', + } + render(table) + expect(useTableViewPinStore.getState().pins['table-1']).toBeUndefined() + + render({ ...table, viewId: 'view-edited' }) + const pin = useTableViewPinStore.getState().pins['table-1'] + expect(pin?.viewId).toBe('view-edited') + + render({ ...table, viewId: 'view-edited' }) + expect(useTableViewPinStore.getState().pins['table-1']?.seq).toBe(pin?.seq) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 82fcd1814e0..b4a6ca4a03a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -62,6 +62,7 @@ import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useExecutionStore } from '@/stores/execution/store' +import { useTableViewPinStore } from '@/stores/table/view-pin/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const Workflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow')) @@ -178,6 +179,25 @@ export const ResourceContent = memo(function ResourceContent({ visible = true, onBrowserOverlayControllerChange, }: ResourceContentProps) { + const observedTableViewRef = useRef( + resource.type === 'table' ? { tableId: resource.id, viewId: resource.viewId } : null + ) + + useEffect(() => { + const previous = observedTableViewRef.current + const next = + resource.type === 'table' ? { tableId: resource.id, viewId: resource.viewId } : null + observedTableViewRef.current = next + if (!next?.viewId || (previous?.tableId === next.tableId && previous.viewId === next.viewId)) { + return + } + /** + * `initialViewId` owns the first table adoption. If refreshed chat data + * supplies it later, use the same one-shot handoff as live stream events. + */ + useTableViewPinStore.getState().pin(next.tableId, next.viewId) + }, [resource.id, resource.type, resource.viewId]) + const streamFileName = previewSession?.fileName || 'file.md' const syntheticFile = useMemo(() => { const ext = getFileExtension(streamFileName) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index 48e7b83e235..374620f48b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -42,6 +42,7 @@ vi.mock('@/lib/api/client/request', async (importOriginal) => ({ import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' +import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' const DEDUPED_CHAT_ID = 'chat-the-first-attempt-opened' @@ -150,13 +151,18 @@ function renderUseChat(): { * pathname has to match: the hook resets a chat-bound surface back to a fresh * pending key when it finds itself on the home route. */ -function renderUseChatInChat(chatId: string): { +function renderUseChatInChat( + chatId: string, + sharedQueryClient: QueryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) +): { getResult: () => ReturnType unmount: () => void } { navigationMocks.usePathname.mockReturnValue(`/workspace/ws-1/chat/${chatId}`) ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient = sharedQueryClient const container = document.createElement('div') const root = createRoot(container) mountedRoots.push(root) @@ -509,4 +515,66 @@ describe('useChat remount send recovery', () => { // Must NOT have gone to the cross-surface handoff. expect(MothershipHandoffStorage.consume('ws-1')).toBeNull() }) + + it('restores the last edited table view after switching away and back', async () => { + const chatId = 'chat-with-table' + const sharedQueryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const initialHistory: MothershipChatHistory = { + id: chatId, + title: 'Table chat', + messages: [], + activeStreamId: null, + resources: [{ type: 'table', id: 'table-1', title: 'Invoices' }], + } + sharedQueryClient.setQueryData(mothershipChatKeys.detail(chatId), initialHistory) + + const firstSurface = renderUseChatInChat(chatId, sharedQueryClient) + await waitFor(() => firstSurface.getResult().resources.length === 1) + + act(() => { + firstSurface.getResult().addResource({ + type: 'table', + id: 'table-1', + title: 'Invoices', + viewId: 'view-edited', + }) + }) + await waitFor(() => firstSurface.getResult().resources[0]?.viewId === 'view-edited') + firstSurface.unmount() + + const restoredSurface = renderUseChatInChat(chatId, sharedQueryClient) + await waitFor(() => restoredSurface.getResult().resources.length === 1) + + expect(restoredSurface.getResult().resources[0]?.viewId).toBe('view-edited') + }) + + it('hydrates a table view change when resource identity and title stay the same', async () => { + const chatId = 'chat-with-refetched-view' + const sharedQueryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const initialHistory: MothershipChatHistory = { + id: chatId, + title: 'Table chat', + messages: [], + activeStreamId: null, + resources: [{ type: 'table', id: 'table-1', title: 'Invoices' }], + } + sharedQueryClient.setQueryData(mothershipChatKeys.detail(chatId), initialHistory) + + const surface = renderUseChatInChat(chatId, sharedQueryClient) + await waitFor(() => surface.getResult().resources.length === 1) + + act(() => { + sharedQueryClient.setQueryData(mothershipChatKeys.detail(chatId), { + ...initialHistory, + resources: [{ type: 'table', id: 'table-1', title: 'Invoices', viewId: 'view-refetched' }], + }) + }) + + await waitFor(() => surface.getResult().resources[0]?.viewId === 'view-refetched') + expect(surface.getResult().resources[0]?.viewId).toBe('view-refetched') + }) }) 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 7e8e1f7741f..9d473ba1eaa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -910,10 +910,19 @@ function markMessageStopped(message: PersistedMessage): PersistedMessage { }) } +function buildChatResourceHydrationKey(resource: MothershipResource): string { + return JSON.stringify([ + resource.type, + resource.id, + resource.title, + resource.path ?? null, + resource.viewId ?? null, + resource.executionId ?? null, + ]) +} + function buildChatHistoryHydrationKey(chatHistory: MothershipChatHistory): string { - const resourceKey = chatHistory.resources - .map((resource) => `${resource.type}:${resource.id}:${resource.title}`) - .join('|') + const resourceKey = chatHistory.resources.map(buildChatResourceHydrationKey).join('|') const messageKey = chatHistory.messages.map((message) => message.id).join('|') const streamSnapshot = chatHistory.streamSnapshot const snapshotKey = streamSnapshot @@ -1841,6 +1850,28 @@ export function useChat( (r) => r.type === resourceUpdate.type && r.id === resourceUpdate.id ) const resource = mergeChatResource(existing, resourceUpdate) + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + if (persistChatId && !isEphemeralResource(resource)) { + queryClient.setQueryData( + mothershipChatKeys.detail(persistChatId), + (current) => { + if (!current) return current + const cached = current.resources.find( + (item) => item.type === resource.type && item.id === resource.id + ) + const merged = mergeChatResource(cached, resourceUpdate) + if (cached === merged) return current + return { + ...current, + resources: cached + ? current.resources.map((item) => + item.type === resource.type && item.id === resource.id ? merged : item + ) + : [...current.resources, merged], + } + } + ) + } if (existing && resource === existing && resourceUpdate.clearViewId !== true) { return false } @@ -1859,12 +1890,11 @@ export function useChat( return true } - const persistChatId = chatIdRef.current ?? selectedChatIdRef.current const persistenceScopeId = persistChatId ?? pendingChatKeyRef.current resourcePersistenceQueue.enqueue(resourceUpdate, persistChatId, existing, persistenceScopeId) return existing === undefined }, - [resourcePersistenceQueue] + [queryClient, resourcePersistenceQueue] ) const removeResource = useCallback( @@ -2456,9 +2486,8 @@ export function useChat( mergedResources.length === resourcesRef.current.length && mergedResources.every( (resource, index) => - resourcesRef.current[index].type === resource.type && - resourcesRef.current[index].id === resource.id && - resourcesRef.current[index].title === resource.title + buildChatResourceHydrationKey(resourcesRef.current[index]) === + buildChatResourceHydrationKey(resource) ) if (mergedResources.length > 0) { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index f2af3da7751..59986788646 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -383,9 +383,13 @@ export function Table({ const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId }) const deleteViewMutation = useDeleteTableView({ workspaceId, tableId }) - /** Resolve the default synchronously so the grid, autosave owner, and menu all - * agree before the URL effect records the adopted view id. */ - const { selectedView, defaultView, activeView } = resolveTableViewSelection(views, activeViewId) + /** Resolve the restored or default view synchronously so the grid, autosave + * owner, and menu agree before the URL effect records the adopted view id. */ + const { selectedView, defaultView, activeView } = resolveTableViewSelection( + views, + activeViewId, + embedded ? initialViewId : undefined + ) const activeViewConfig = useMemo( () => resolveTableViewConfig(tableData?.metadata, activeView?.config ?? null), [tableData?.metadata, activeView?.config] @@ -662,6 +666,9 @@ export function Table({ return } + if (activeView && activeViewId === null) { + setTableParams({ view: activeView.id }) + } const nextViewRevision = getTableViewRevision(activeView) if ( !shouldApplyTableViewRevision( @@ -678,7 +685,7 @@ export function Table({ if (preserved && preserved.viewId !== nextViewId) { preservedViewStateRef.current = null } - if (activeView && (activeViewId === null || activeViewId === ALL_VIEW_PARAM)) { + if (activeView && activeViewId === ALL_VIEW_PARAM) { setTableParams({ view: activeView.id }) } const keep = preserved?.viewId === nextViewId ? preserved.keep : undefined diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts index 2e3b9eeca78..b860963115f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts @@ -45,6 +45,13 @@ const DEFAULT_VIEW: TableViewWire = { updatedAt: new Date('2026-08-15T01:10:00.000Z'), } +const PINNED_VIEW: TableViewWire = { + ...DEFAULT_VIEW, + id: 'view-pinned', + name: 'Pinned', + isDefault: false, +} + describe('resolveTableViewSelection', () => { it('makes the persisted default active before its URL id is adopted', () => { expect(resolveTableViewSelection([DEFAULT_VIEW], null)).toEqual({ @@ -75,6 +82,19 @@ describe('resolveTableViewSelection', () => { }) }) + it('keeps a restored embedded view active while the host URL is absent', () => { + expect( + resolveTableViewSelection([DEFAULT_VIEW, PINNED_VIEW], null, PINNED_VIEW.id).activeView + ).toBe(PINNED_VIEW) + }) + + it('lets an explicit URL selection override the restored embedded view', () => { + expect( + resolveTableViewSelection([DEFAULT_VIEW, PINNED_VIEW], DEFAULT_VIEW.id, PINNED_VIEW.id) + .activeView + ).toBe(DEFAULT_VIEW) + }) + it('upgrades the legacy All sentinel when a persisted default exists', () => { expect(resolveTableViewSelection([DEFAULT_VIEW], ALL_VIEW_PARAM).activeView).toBe(DEFAULT_VIEW) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts index 21cebfcdcb8..4386ba88912 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts @@ -21,26 +21,33 @@ export function resolveTableViewConfig( } /** - * Resolves the persisted default synchronously when the URL has not selected a - * view yet. The URL effect still records that choice, but render-time consumers - * all see the same owner while that update is pending. + * Resolves a restored embedded view, then the persisted default, while the URL + * has no selection. The URL effect still records that choice, but render-time + * consumers all see the same owner while that update is pending. */ export function resolveTableViewSelection( views: TableViewWire[], - activeViewId: string | null + activeViewId: string | null, + restoredViewId?: string ): TableViewSelection { let selectedView: TableViewWire | null = null let defaultView: TableViewWire | null = null + let restoredView: TableViewWire | null = null for (const view of views) { if (view.id === activeViewId) selectedView = view if (view.isDefault) defaultView = view + if (view.id === restoredViewId) restoredView = view } return { selectedView, defaultView, activeView: selectedView ?? - (activeViewId === null || activeViewId === ALL_VIEW_PARAM ? defaultView : null), + (activeViewId === null + ? (restoredView ?? defaultView) + : activeViewId === ALL_VIEW_PARAM + ? defaultView + : null), } } From 390e1b3febf9eecb1efc87fd5ac7227d8e24df87 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 3 Sep 2026 12:06:31 -0700 Subject: [PATCH 16/17] fix(copilot): bound resource-write locks and repair reorder persistence Review follow-ups on the saved-view pinning work. Correctness: - Reorder persistence was parked by ANY pending write. A repeatedly failing update to an already-stored resource (a view pin) blocked tab ordering for the rest of the session; gate on unpersisted writes only, which are the ones the server's identity check can actually reject. - A parked reorder body that the server rejects was re-parked verbatim, so a tab closed after the order was captured poisoned it permanently. Discard on 400; keep retrying everything transient. - adoptScope merged the provisional and chat-scoped updates in the wrong order, letting an older pending write overwrite a newer one. - A view pin that arrived before the table finished its first adoption was dropped for good when the table data resolved after the views list. The stream path self-rescued through query invalidation; the restore path did not. Re-run the effect when adoption becomes possible. - Reordering a chat holding a legacy duplicate row 400'd forever. Compare identity sets so the duplicate collapses on write instead. - mergeChatResource aliased the caller's object into React state, the query cache and the pending-write queue at once. Copy it. Robustness: - The new copilot_chats FOR UPDATE transactions had no lock_timeout, and neither the pool nor the deployment sets one. finalizeAssistantTurn holds that same row across an assistant-message append, so a waiter could park a pool connection indefinitely. Bound all five writers. - mergeChatResource's field list is now one declaration that fails to compile when MothershipResource gains a field, rather than silently dropping it from both the merge and its no-op check. - Extraction can no longer emit viewId and clearViewId together, a pair the wire contract rejects and the merge would resolve to neither. - Restrict the eager view-id URL write to embedded tables, leaving standalone table behaviour identical to staging. - Drop the queue's unreachable unscoped bucket, its uncalled clear(), and its test-only getPendingUpdates(). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FwmaLmAXSK2hsPBGZmkPnT --- .../app/api/copilot/chat/resources/route.ts | 9 ++- .../[workspaceId]/home/hooks/use-chat.ts | 20 ++++- .../[workspaceId]/tables/[tableId]/table.tsx | 24 +++++- .../client-persistence-queue.test.ts | 79 +++++++++++++++---- .../resources/client-persistence-queue.ts | 59 +++++++------- apps/sim/lib/copilot/resources/extraction.ts | 10 ++- apps/sim/lib/copilot/resources/persistence.ts | 28 +++++++ apps/sim/lib/copilot/resources/types.test.ts | 15 +++- apps/sim/lib/copilot/resources/types.ts | 37 ++++++--- 9 files changed, 217 insertions(+), 64 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 0ec97cbe86b..52f5c1e6c5e 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -16,7 +16,11 @@ import { createNotFoundResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' -import { type ChatResource, serializeChatResourceWrite } from '@/lib/copilot/resources/persistence' +import { + type ChatResource, + serializeChatResourceWrite, + setChatResourceTxTimeouts, +} from '@/lib/copilot/resources/persistence' import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' import { canonicalizeDesktopSessionResource, @@ -57,6 +61,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const merged = await serializeChatResourceWrite(chatId, () => db.transaction(async (tx) => { + await setChatResourceTxTimeouts(tx) const scope = and( eq(copilotChats.id, chatId), eq(copilotChats.userId, userId), @@ -125,6 +130,7 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => { const canonicalOrder = await serializeChatResourceWrite(chatId, () => db.transaction(async (tx): Promise => { + await setChatResourceTxTimeouts(tx) const scope = and( eq(copilotChats.id, chatId), eq(copilotChats.userId, userId), @@ -191,6 +197,7 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => { const merged = await serializeChatResourceWrite(chatId, () => db.transaction(async (tx) => { + await setChatResourceTxTimeouts(tx) const scope = and( eq(copilotChats.id, chatId), eq(copilotChats.userId, userId), 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 9d473ba1eaa..d1f0229ba50 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -18,6 +18,7 @@ import { isRecordLike } from '@sim/utils/object' import { backoffWithJitter } from '@sim/utils/retry' import { useQueryClient } from '@tanstack/react-query' import { usePathname, useRouter } from 'next/navigation' +import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { addMothershipChatResourceContract, @@ -1706,7 +1707,7 @@ export function useChat( while (true) { const pendingOrder = pendingResourceReordersRef.current.get(chatId) if (!pendingOrder) return - if (resourcePersistenceQueue.getPendingResourceKeys(chatId).size > 0) return + if (resourcePersistenceQueue.hasUnpersistedWrites(chatId)) return const inFlightWrites = resourcePersistenceQueue.getInFlightWrites(chatId) if (inFlightWrites.length > 0) { @@ -1721,10 +1722,21 @@ export function useChat( body: { chatId, resources: pendingOrder }, }) } catch (error) { - if (!pendingResourceReordersRef.current.has(chatId)) { + // 400 is the server rejecting the body's identity set — a tab was + // closed after this order was captured. Replaying it verbatim can + // only fail again, so drop it and let the next reorder or hydration + // re-establish the order. Everything else (offline, 401, 5xx) is + // transient and keeps the body for the next retry. + const unsatisfiable = isApiClientError(error) && error.status === 400 + if (!unsatisfiable && !pendingResourceReordersRef.current.has(chatId)) { pendingResourceReordersRef.current.set(chatId, pendingOrder) } - logger.warn('Failed to persist resource reorder; will retry on next hydration', error) + logger.warn( + unsatisfiable + ? 'Discarded a resource reorder the server rejected' + : 'Failed to persist resource reorder; will retry on next hydration', + error + ) return } } @@ -1891,7 +1903,7 @@ export function useChat( } const persistenceScopeId = persistChatId ?? pendingChatKeyRef.current - resourcePersistenceQueue.enqueue(resourceUpdate, persistChatId, existing, persistenceScopeId) + resourcePersistenceQueue.enqueue(resourceUpdate, persistChatId, persistenceScopeId, existing) return existing === undefined }, [queryClient, resourcePersistenceQueue] diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 59986788646..a6fbe933f0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -666,7 +666,11 @@ export function Table({ return } - if (activeView && activeViewId === null) { + // Embedded tables record the adopted id BEFORE the revision guard can bail: + // `resolveTableViewSelection` resolves a null param to the restored view, so + // leaving the param unwritten lets a later render drift back to the default. + // Standalone tables have no restored view and keep writing it below. + if (embedded && activeView && activeViewId === null) { setTableParams({ view: activeView.id }) } const nextViewRevision = getTableViewRevision(activeView) @@ -685,7 +689,7 @@ export function Table({ if (preserved && preserved.viewId !== nextViewId) { preservedViewStateRef.current = null } - if (activeView && activeViewId === ALL_VIEW_PARAM) { + if (activeView && (activeViewId === null || activeViewId === ALL_VIEW_PARAM)) { setTableParams({ view: activeView.id }) } const keep = preserved?.viewId === nextViewId ? preserved.keep : undefined @@ -737,7 +741,21 @@ export function Table({ if (!transition.nextViewId) return preservedViewStateRef.current = null setTableParams({ view: transition.nextViewId }) - }, [embedded, viewPin, views, activeViewId, tableId, consumeViewPin, setTableParams]) + // `viewsAvailable`/`tableAvailable` are what gate first adoption, and + // adoption records itself in a ref, which re-renders nothing. Without them + // a pin that arrives before the table is ready is never reconsidered — the + // restore path has no query invalidation to nudge `views` and rescue it. + }, [ + embedded, + viewPin, + views, + activeViewId, + tableId, + viewsAvailable, + tableAvailable, + consumeViewPin, + setTableParams, + ]) /** * Live state pruned the same way `pruneViewConfig` prunes the stored config on diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts index 3ef88622cff..07357f59ed8 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts @@ -36,8 +36,8 @@ describe('ResourcePersistenceQueue', () => { .mockResolvedValueOnce({ success: true }) const queue = new ResourcePersistenceQueue({ persist, onError }) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1') - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1', 'chat-1') await Promise.resolve() expect(persist).toHaveBeenCalledTimes(1) @@ -59,12 +59,11 @@ describe('ResourcePersistenceQueue', () => { .mockResolvedValueOnce({ success: true }) const queue = new ResourcePersistenceQueue({ persist, onError }) - queue.enqueue({ ...TABLE_RESOURCE, clearViewId: true }, 'chat-1') - queue.enqueue(TABLE_RESOURCE, 'chat-1') + queue.enqueue({ ...TABLE_RESOURCE, clearViewId: true }, 'chat-1', 'chat-1') + queue.enqueue(TABLE_RESOURCE, 'chat-1', 'chat-1') first.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) - expect(queue.getPendingUpdates('chat-1')).toEqual([{ ...TABLE_RESOURCE, clearViewId: true }]) await queue.flush('chat-1') expect(persist.mock.calls[1]).toEqual(['chat-1', { ...TABLE_RESOURCE, clearViewId: true }]) @@ -81,10 +80,10 @@ describe('ResourcePersistenceQueue', () => { const remove = vi.fn().mockResolvedValue({ success: true }) const queue = new ResourcePersistenceQueue({ persist, onError }) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1') const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') removal.scheduleDelete('chat-1', remove) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1', 'chat-1') await Promise.resolve() expect(persist).toHaveBeenCalledTimes(1) @@ -106,7 +105,7 @@ describe('ResourcePersistenceQueue', () => { .mockReturnValueOnce(failed.promise) const queue = new ResourcePersistenceQueue({ persist, onError }) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', TABLE_RESOURCE) + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1', TABLE_RESOURCE) failed.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) @@ -125,7 +124,7 @@ describe('ResourcePersistenceQueue', () => { .mockReturnValueOnce(failed.promise) const queue = new ResourcePersistenceQueue({ persist, onError }) - queue.enqueue(TABLE_RESOURCE, 'chat-1') + queue.enqueue(TABLE_RESOURCE, 'chat-1', 'chat-1') failed.reject(new Error('offline')) await Promise.allSettled(Array.from(queue.inFlight.values())) @@ -142,13 +141,13 @@ describe('ResourcePersistenceQueue', () => { const remove = vi.fn().mockReturnValue(deletion.promise) const queue = new ResourcePersistenceQueue({ persist, onError }) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', TABLE_RESOURCE) + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1', TABLE_RESOURCE) await Promise.allSettled(Array.from(queue.inFlight.values())) const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') removal.scheduleDelete('chat-1', remove) await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1', 'chat-1') await Promise.resolve() expect(persist).toHaveBeenCalledOnce() @@ -188,10 +187,10 @@ describe('ResourcePersistenceQueue', () => { .mockResolvedValue({ success: true }) const queue = new ResourcePersistenceQueue({ persist, onError }) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1') await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-2') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-2', 'chat-2') await vi.waitFor(() => expect(persist).toHaveBeenCalledTimes(2)) expect(persist.mock.calls[1]).toEqual(['chat-2', { ...TABLE_RESOURCE, viewId: 'view-b' }]) @@ -210,7 +209,7 @@ describe('ResourcePersistenceQueue', () => { .mockResolvedValue({ success: true }) const queue = new ResourcePersistenceQueue({ persist, onError }) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, undefined, undefined, 'pending-chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, undefined, 'pending-chat-1') expect(queue.getPendingResourceKeys('pending-chat-1')).toEqual(new Set(['table:table-1'])) await queue.flush('chat-1', 'pending-chat-1') @@ -220,6 +219,54 @@ describe('ResourcePersistenceQueue', () => { expect(queue.getPendingResourceKeys('chat-1')).toEqual(new Set()) }) + it('does not report an unpersisted write once the resource reaches the server', async () => { + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockResolvedValueOnce({ success: true }) + .mockRejectedValue(new Error('offline')) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue(TABLE_RESOURCE, 'chat-1', 'chat-1') + await Promise.allSettled(queue.getInFlightWrites('chat-1')) + expect(queue.hasUnpersistedWrites('chat-1')).toBe(false) + + // A pin update for the same, already-stored resource keeps failing. The + // resource is on the server, so a reorder naming it stays valid. + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1') + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(queue.getPendingResourceKeys('chat-1')).toEqual(new Set(['table:table-1'])) + expect(queue.hasUnpersistedWrites('chat-1')).toBe(false) + + await queue.flush('chat-1') + expect(queue.hasUnpersistedWrites('chat-1')).toBe(false) + }) + + it('reports an unpersisted write while a first add has never succeeded', async () => { + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockRejectedValue(new Error('offline')) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue(TABLE_RESOURCE, 'chat-1', 'chat-1') + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + + expect(queue.hasUnpersistedWrites('chat-1')).toBe(true) + }) + + it('keeps the newer chat-scoped update when a provisional scope is adopted', async () => { + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockResolvedValue({ success: true }) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, undefined, 'pending-chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, undefined, 'chat-1') + await queue.flush('chat-1', 'pending-chat-1') + + expect(persist).toHaveBeenCalledTimes(1) + expect(persist).toHaveBeenCalledWith('chat-1', { ...TABLE_RESOURCE, viewId: 'view-b' }) + }) + it('remembers a stored resource until a failed deletion eventually succeeds', async () => { const persist = vi .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() @@ -228,14 +275,14 @@ describe('ResourcePersistenceQueue', () => { const remove = vi.fn().mockRejectedValueOnce(new Error('delete failed')) const queue = new ResourcePersistenceQueue({ persist, onError }) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', TABLE_RESOURCE) + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1', TABLE_RESOURCE) await Promise.allSettled(queue.getInFlightWrites('chat-1')) const firstRemoval = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') firstRemoval.scheduleDelete('chat-1', remove) await Promise.allSettled(queue.getInFlightWrites('chat-1')) - queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1') + queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-b' }, 'chat-1', 'chat-1') await Promise.allSettled(queue.getInFlightWrites('chat-1')) const secondRemoval = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.ts index 2a989756409..786123cec55 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.ts @@ -6,7 +6,6 @@ interface ResourcePersistenceQueueOptions { onError: (error: unknown) => void } -const UNSCOPED_QUEUE_ID = 'unscoped' const QUEUE_KEY_SEPARATOR = '\u0000' export interface RemovedResourcePersistence { @@ -40,11 +39,18 @@ export class ResourcePersistenceQueue { this.onError = onError } + /** + * Records the newest desired state for a resource and starts writing it when + * a chat id is known. `scopeId` buckets the write: it is the chat id once the + * chat exists, and the provisional pending-chat key before that, which + * {@link flush} later adopts. `base` is the resource as the server already + * holds it, when the caller knows. + */ enqueue( update: MothershipResourceUpdate, chatId: string | undefined, - base?: MothershipResource, - scopeId: string | undefined = chatId + scopeId: string, + base?: MothershipResource ): void { const key = this.getKey(scopeId, update.type, update.id) const trackedLocally = @@ -79,7 +85,7 @@ export class ResourcePersistenceQueue { remove( type: string, id: string, - scopeId: string | undefined, + scopeId: string, assumePersisted = false ): RemovedResourcePersistence { const key = this.getKey(scopeId, type, id) @@ -110,38 +116,32 @@ export class ResourcePersistenceQueue { } } - getPendingUpdates(scopeId?: string): MothershipResourceUpdate[] { - return this.getScopedKeys(this.pendingKeys, scopeId).flatMap((key) => { - const update = this.desiredUpdates.get(key) - return update ? [update] : [] - }) + /** + * Whether a pending write would add a resource the server does not store yet. + * + * Only these may hold a reorder back — the server rejects an order naming a + * resource it has never seen. A pending UPDATE to a resource it already + * stores (a saved-view pin) must not: that write can fail indefinitely, and + * gating on it would park tab ordering for the rest of the session. + */ + hasUnpersistedWrites(scopeId: string): boolean { + return this.getScopedKeys(this.pendingKeys, scopeId).some((key) => !this.persistedKeys.has(key)) } - getPendingResourceKeys(scopeId?: string): Set { + getPendingResourceKeys(scopeId: string): Set { const prefix = this.getScopePrefix(scopeId) return new Set( this.getScopedKeys(this.pendingKeys, scopeId).map((key) => key.slice(prefix.length)) ) } - getInFlightWrites(scopeId?: string): Promise[] { + getInFlightWrites(scopeId: string): Promise[] { return this.getScopedKeys(this.inFlight, scopeId).flatMap((key) => { const write = this.inFlight.get(key) return write ? [write] : [] }) } - clear(): void { - this.pendingKeys.clear() - this.inFlight.clear() - this.desiredUpdates.clear() - this.failedKeys.clear() - this.pendingRemovals.clear() - this.persistedKeys.clear() - this.removalTokens.clear() - this.writeTokens.clear() - } - private startPending(chatId: string): void { for (const key of this.getScopedKeys(this.pendingKeys, chatId)) { if (this.failedKeys.has(key) || this.inFlight.has(key)) continue @@ -213,7 +213,10 @@ export class ResourcePersistenceQueue { return result }) .catch((error) => { + // Superseded by a newer write for the same key, which owns the retry. if (this.writeTokens.get(key) !== token) return + // The resource was removed while this write was in flight. Nothing is + // left to retry, and `onError` would report a retry that never comes. if (!this.desiredUpdates.has(key)) return this.pendingKeys.add(key) this.failedKeys.add(key) @@ -243,9 +246,11 @@ export class ResourcePersistenceQueue { const sourceUpdate = this.desiredUpdates.get(sourceKey) const targetUpdate = this.desiredUpdates.get(targetKey) if (sourceUpdate) { + // The source scope is the provisional pre-chat-id bucket, so its update + // is the OLDER of the two: it is `prev`, and the target's is `next`. this.desiredUpdates.set( targetKey, - targetUpdate ? mergePendingChatResourceUpdate(targetUpdate, sourceUpdate) : sourceUpdate + targetUpdate ? mergePendingChatResourceUpdate(sourceUpdate, targetUpdate) : sourceUpdate ) } this.desiredUpdates.delete(sourceKey) @@ -258,17 +263,17 @@ export class ResourcePersistenceQueue { private getScopedKeys( collection: ReadonlySet | ReadonlyMap, - scopeId?: string + scopeId: string ): string[] { const prefix = this.getScopePrefix(scopeId) return Array.from(collection.keys()).filter((key) => key.startsWith(prefix)) } - private getScopePrefix(scopeId?: string): string { - return `${scopeId ?? UNSCOPED_QUEUE_ID}${QUEUE_KEY_SEPARATOR}` + private getScopePrefix(scopeId: string): string { + return `${scopeId}${QUEUE_KEY_SEPARATOR}` } - private getKey(scopeId: string | undefined, type: string, id: string): string { + private getKey(scopeId: string, type: string, id: string): string { return `${this.getScopePrefix(scopeId)}${type}:${id}` } } diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 258cd36ea89..80b70e241ec 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -209,13 +209,19 @@ export function extractResourcesFromToolResult( const tableId = (data.tableId as string) ?? (args.tableId as string) if (!tableId) return [] const viewId = data.viewId + // Pin and unpin are mutually exclusive: the wire contract rejects the + // pair, and a merge handed both would apply neither. A delete unpins + // regardless of any view id its result happens to carry. return [ { type: 'table', id: tableId, title: (data.tableName as string) || 'Table', - ...(typeof viewId === 'string' && viewId ? { viewId } : {}), - ...(operation === 'delete_view' ? { clearViewId: true as const } : {}), + ...(operation === 'delete_view' + ? { clearViewId: true as const } + : typeof viewId === 'string' && viewId + ? { viewId } + : {}), }, ] } diff --git a/apps/sim/lib/copilot/resources/persistence.ts b/apps/sim/lib/copilot/resources/persistence.ts index c4f7230b136..7788348c479 100644 --- a/apps/sim/lib/copilot/resources/persistence.ts +++ b/apps/sim/lib/copilot/resources/persistence.ts @@ -25,6 +25,32 @@ const logger = createLogger('CopilotResources') type ChatResource = MothershipResource +const CHAT_RESOURCE_STATEMENT_TIMEOUT_MS = 10_000 +const CHAT_RESOURCE_LOCK_TIMEOUT_MS = 3_000 +const CHAT_RESOURCE_IDLE_TIMEOUT_MS = 5_000 + +/** + * Bounds the `copilot_chats` row-lock wait every resource writer takes. + * + * {@link serializeChatResourceWrite} only serializes writers inside one process. + * Another pod's write — and `finalizeAssistantTurn`, which holds this same row + * `FOR UPDATE` across an assistant-message append — are outside it. Without + * `lock_timeout` a waiter inherits the full statement clock, which the + * deployment does not set either, so one stuck holder can drain the pool. + * + * Safe under pgBouncer transaction pooling: `SET LOCAL` is transaction-scoped + * and clears at COMMIT/ROLLBACK before the session returns to the pool. + */ +export async function setChatResourceTxTimeouts(trx: Pick): Promise { + await trx.execute( + sql.raw(`SET LOCAL statement_timeout = '${CHAT_RESOURCE_STATEMENT_TIMEOUT_MS}ms'`) + ) + await trx.execute(sql.raw(`SET LOCAL lock_timeout = '${CHAT_RESOURCE_LOCK_TIMEOUT_MS}ms'`)) + await trx.execute( + sql.raw(`SET LOCAL idle_in_transaction_session_timeout = '${CHAT_RESOURCE_IDLE_TIMEOUT_MS}ms'`) + ) +} + const chatResourceWriteChain = new Map>() export async function serializeChatResourceWrite( @@ -55,6 +81,7 @@ export async function persistChatResources( try { await serializeChatResourceWrite(chatId, async () => { await db.transaction(async (tx) => { + await setChatResourceTxTimeouts(tx) const [chat] = await tx .select({ resources: copilotChats.resources }) .from(copilotChats) @@ -103,6 +130,7 @@ export async function removeChatResources(chatId: string, toRemove: ChatResource try { await serializeChatResourceWrite(chatId, async () => { await db.transaction(async (tx) => { + await setChatResourceTxTimeouts(tx) const [chat] = await tx .select({ resources: copilotChats.resources }) .from(copilotChats) diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/copilot/resources/types.test.ts index 33e36aa0f1f..7510ad5d36a 100644 --- a/apps/sim/lib/copilot/resources/types.test.ts +++ b/apps/sim/lib/copilot/resources/types.test.ts @@ -184,8 +184,12 @@ describe('unaddressable resources', () => { describe('mergeChatResource', () => { const stored = resource({ type: 'table', id: 'tbl-1', title: 'Invoices' }) - it('adds a resource the chat does not have yet', () => { - expect(mergeChatResource(undefined, stored)).toBe(stored) + it('adds a resource the chat does not have yet as a copy', () => { + const added = mergeChatResource(undefined, stored) + expect(added).toEqual(stored) + // Copied, not aliased: the result is handed to React state, the query cache + // and the pending-write queue, and the caller keeps mutating its own object. + expect(added).not.toBe(stored) }) it('keeps the stored entry when the newcomer changes nothing', () => { @@ -287,4 +291,11 @@ describe('reorderStoredChatResources', () => { expect(reorderStoredChatResources([table], [table, file])).toBeNull() expect(reorderStoredChatResources([table, file], [table, table])).toBeNull() }) + + it('collapses a duplicated stored row instead of rejecting the reorder', () => { + // Nothing writes a duplicate today, but a chat stored before the writers + // merged by key can hold one. The client sends its deduplicated list, so a + // length comparison would reject every reorder for that chat forever. + expect(reorderStoredChatResources([table, table, file], [file, table])).toEqual([file, table]) + }) }) diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 87011412e6c..b437f686901 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -219,13 +219,19 @@ export function reorderStoredChatResources( ): MothershipResource[] | null { const stored = sanitizeChatResources(storedResources) const requested = sanitizeChatResources(requestedOrder) - if (stored.length !== requested.length) return null + // Compared as key SETS, not lengths: a chat that already holds a duplicated + // row (nothing writes one today, but stored data predates the merge-by-key + // writers) sends one fewer entry from the deduplicated client. Matching on + // sets keeps that reorder valid and collapses the duplicate on write, where + // a length check would reject every reorder for that chat forever. const storedByKey = new Map( stored.map((resource) => [`${resource.type}:${resource.id}`, resource]) ) - const requestedKeys = requested.map((resource) => `${resource.type}:${resource.id}`) - if (new Set(requestedKeys).size !== storedByKey.size) return null + const requestedKeys = Array.from( + new Set(requested.map((resource) => `${resource.type}:${resource.id}`)) + ) + if (requestedKeys.length !== storedByKey.size) return null const reordered: MothershipResource[] = [] for (const key of requestedKeys) { @@ -246,6 +252,22 @@ export const GENERIC_RESOURCE_TITLES = new Set([ 'Log', ]) +/** + * Every field {@link mergeChatResource} carries over from the newcomer. `type` + * and `id` identify the entry and can never differ; `title` has its own + * placeholder rule. Declared once so a field added to {@link MothershipResource} + * fails to compile here rather than being silently dropped from both the merge + * and its no-op check. + */ +const MERGED_FIELDS = { + title: true, + path: true, + viewId: true, + executionId: true, +} as const satisfies Record, true> + +const MERGED_FIELD_NAMES = Object.keys(MERGED_FIELDS) as (keyof typeof MERGED_FIELDS)[] + /** * Folds a re-added resource into the stored entry with the same type+id. The * stored title wins unless it was a placeholder. Every other field the @@ -260,7 +282,8 @@ export function mergeChatResource( next: MothershipResourceUpdate ): MothershipResource { if (!prev) { - if (next.clearViewId !== true) return next + // Copied, never aliased: the result lands in React state, the query cache + // and the pending-write queue at once, and `next` is the caller's object. const { clearViewId: _clearViewId, ...resource } = next return resource } @@ -275,11 +298,7 @@ export function mergeChatResource( ? next.title : prev.title, } - const unchanged = - merged.title === prev.title && - merged.path === prev.path && - merged.viewId === prev.viewId && - merged.executionId === prev.executionId + const unchanged = MERGED_FIELD_NAMES.every((field) => merged[field] === prev[field]) return unchanged ? prev : merged } From 081b5cf65e8ebc3afaff4fb607e9bef862886c56 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 3 Sep 2026 12:10:40 -0700 Subject: [PATCH 17/17] fix(copilot): hold a reorder for pending deletes too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reorder gate landed one case short: a delete that has not reached the server leaves the server holding a resource the client's order omits, so the order fails its identity check exactly as an unlanded add does. Gating only on unpersisted adds let that order fire and be discarded as unsatisfiable, losing the tab order until the next reorder or hydration. Name the predicate for what it actually decides — whether a pending write changes WHICH resources the chat holds — and cover both directions. A failing update to an already-stored resource still does not park the order, which is what the gate was narrowed for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FwmaLmAXSK2hsPBGZmkPnT --- .../[workspaceId]/home/hooks/use-chat.ts | 2 +- .../client-persistence-queue.test.ts | 30 +++++++++++++++---- .../resources/client-persistence-queue.ts | 18 ++++++----- 3 files changed, 37 insertions(+), 13 deletions(-) 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 d1f0229ba50..f19aebfee9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1707,7 +1707,7 @@ export function useChat( while (true) { const pendingOrder = pendingResourceReordersRef.current.get(chatId) if (!pendingOrder) return - if (resourcePersistenceQueue.hasUnpersistedWrites(chatId)) return + if (resourcePersistenceQueue.hasPendingIdentityChanges(chatId)) return const inFlightWrites = resourcePersistenceQueue.getInFlightWrites(chatId) if (inFlightWrites.length > 0) { diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts index 07357f59ed8..7335ef7006e 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts @@ -219,7 +219,27 @@ describe('ResourcePersistenceQueue', () => { expect(queue.getPendingResourceKeys('chat-1')).toEqual(new Set()) }) - it('does not report an unpersisted write once the resource reaches the server', async () => { + it('reports a pending identity change while a deletion has not landed', async () => { + const persist = vi + .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() + .mockResolvedValue({ success: true }) + const remove = vi.fn<() => Promise>().mockRejectedValueOnce(new Error('offline')) + const queue = new ResourcePersistenceQueue({ persist, onError }) + + queue.enqueue(TABLE_RESOURCE, 'chat-1', 'chat-1') + await Promise.allSettled(queue.getInFlightWrites('chat-1')) + expect(queue.hasPendingIdentityChanges('chat-1')).toBe(false) + + // The server still holds a resource the client has dropped, so an order + // built from client state would not match and must wait for the delete. + const removal = queue.remove(TABLE_RESOURCE.type, TABLE_RESOURCE.id, 'chat-1') + removal.scheduleDelete('chat-1', remove) + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + + expect(queue.hasPendingIdentityChanges('chat-1')).toBe(true) + }) + + it('does not report an identity change for a failing update to a stored resource', async () => { const persist = vi .fn<(chatId: string, update: MothershipResourceUpdate) => Promise>() .mockResolvedValueOnce({ success: true }) @@ -228,17 +248,17 @@ describe('ResourcePersistenceQueue', () => { queue.enqueue(TABLE_RESOURCE, 'chat-1', 'chat-1') await Promise.allSettled(queue.getInFlightWrites('chat-1')) - expect(queue.hasUnpersistedWrites('chat-1')).toBe(false) + expect(queue.hasPendingIdentityChanges('chat-1')).toBe(false) // A pin update for the same, already-stored resource keeps failing. The // resource is on the server, so a reorder naming it stays valid. queue.enqueue({ ...TABLE_RESOURCE, viewId: 'view-a' }, 'chat-1', 'chat-1') await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) expect(queue.getPendingResourceKeys('chat-1')).toEqual(new Set(['table:table-1'])) - expect(queue.hasUnpersistedWrites('chat-1')).toBe(false) + expect(queue.hasPendingIdentityChanges('chat-1')).toBe(false) await queue.flush('chat-1') - expect(queue.hasUnpersistedWrites('chat-1')).toBe(false) + expect(queue.hasPendingIdentityChanges('chat-1')).toBe(false) }) it('reports an unpersisted write while a first add has never succeeded', async () => { @@ -250,7 +270,7 @@ describe('ResourcePersistenceQueue', () => { queue.enqueue(TABLE_RESOURCE, 'chat-1', 'chat-1') await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) - expect(queue.hasUnpersistedWrites('chat-1')).toBe(true) + expect(queue.hasPendingIdentityChanges('chat-1')).toBe(true) }) it('keeps the newer chat-scoped update when a provisional scope is adopted', async () => { diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.ts b/apps/sim/lib/copilot/resources/client-persistence-queue.ts index 786123cec55..de031f55559 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.ts +++ b/apps/sim/lib/copilot/resources/client-persistence-queue.ts @@ -117,15 +117,19 @@ export class ResourcePersistenceQueue { } /** - * Whether a pending write would add a resource the server does not store yet. + * Whether a pending write would change which resources the chat holds — an + * add the server has not accepted yet, or a delete that has not landed. * - * Only these may hold a reorder back — the server rejects an order naming a - * resource it has never seen. A pending UPDATE to a resource it already - * stores (a saved-view pin) must not: that write can fail indefinitely, and - * gating on it would park tab ordering for the rest of the session. + * Only these may hold a reorder back, because only these make the client's + * identity set disagree with the server's, and the server validates a reorder + * against exactly that. A pending UPDATE to a resource it already stores (a + * saved-view pin) must NOT: such a write can fail indefinitely, and gating on + * it would park tab ordering for the rest of the session. */ - hasUnpersistedWrites(scopeId: string): boolean { - return this.getScopedKeys(this.pendingKeys, scopeId).some((key) => !this.persistedKeys.has(key)) + hasPendingIdentityChanges(scopeId: string): boolean { + return this.getScopedKeys(this.pendingKeys, scopeId).some( + (key) => !this.persistedKeys.has(key) || this.pendingRemovals.has(key) + ) } getPendingResourceKeys(scopeId: string): Set {