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 1/4] 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 2/4] 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 3/4] 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 4/4] 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',