diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 85b81323a65..8e80e559a58 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) @@ -144,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/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..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 @@ -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,85 @@ 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', + }) + // 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, + '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..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 @@ -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,22 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven completedPreviewResourceHandoffRef.current.delete(resource.id) previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId) } + if (pinnedViewId) { + // 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) + } invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id) if (!shouldSuppressFileResourceActivation) onResourceEvent?.(resource.id) 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/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..71156a84a40 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -99,14 +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(), - }), + resource: copilotChatResourceItemSchema, }) export type AddCopilotChatResourceBody = z.input @@ -119,13 +124,7 @@ export type RemoveCopilotChatResourceBody = z.input @@ -423,6 +422,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 b50898eb45a..ea8beb08ad6 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -5786,9 +5786,9 @@ 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.', + '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', @@ -5807,9 +5807,9 @@ 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.', + '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 7625f38c296..9e13d8e9bab 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -5718,9 +5718,9 @@ 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.', + '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', @@ -5741,9 +5741,9 @@ 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.', + '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', 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..49e70a29750 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -194,3 +194,63 @@ describe('extractDeletedResourcesFromToolResult', () => { ).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }]) }) }) + +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: 'Deleted view "Overdue"', + data: { tableId: 'tbl_1', tableName: 'Invoices' }, + } + ) + ).toEqual([{ type: 'table', id: 'tbl_1', title: 'Invoices' }]) + }) + + it.each(['list_views', 'get_view'])('%s opens nothing', (operation) => { + expect( + extractResourcesFromToolResult( + '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 2f47680dfaf..cc555b3c140 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -13,6 +13,7 @@ import { PrepareFileEdit, Rm, RunFunction, + TableViews, UserTable, } from '@/lib/copilot/generated/tool-catalog-v1' import type { MothershipResource, MothershipResourceType } from './types' @@ -22,6 +23,7 @@ type ResourceType = MothershipResourceType const RESOURCE_TOOL_NAMES: Set = new Set([ UserTable.id, + TableViews.id, CreateEmptyFile.id, PrepareFileEdit.id, DownloadFile.id, @@ -52,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']) @@ -196,6 +199,25 @@ export function extractResourcesFromToolResult( 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 [ + { + 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..99dcf36c43d 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,56 @@ 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) + }) +}) + +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 58d68d6e737..3bc585c8235 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -212,6 +212,38 @@ 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. 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 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 = { tables: 'table', files: 'file', 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 7b004bb1b99..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,6 +1,7 @@ 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 { OrchestrationError } from '@/lib/core/orchestration/types' import type { SortSpec, TablePredicateInput, TableSchema, TableViewConfig } from '@/lib/table' import { createTableViewUseCase, @@ -9,7 +10,11 @@ import { readTableViewUseCase, updateTableViewUseCase, } from '@/lib/table/application/views' -import { viewConfigIdsToNames, viewConfigNamesToIds } from '@/lib/table/views/service' +import { + TableViewValidationError, + viewConfigIdsToNames, + viewConfigNamesToIds, +} from '@/lib/table/views/service' type TableViewsArgs = { operation: string @@ -22,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, @@ -39,10 +48,7 @@ export const tableViewsServerTool: BaseServerTool { + const presentView = (view: StoredView, columns: TableSchema['columns']) => { const named = viewConfigIdsToNames(view.config, columns) return { id: view.id, @@ -54,16 +60,38 @@ export const tableViewsServerTool: BaseServerTool ({ + 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". + // 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[] - return viewConfigNamesToIds(patch as TableViewConfig, columns) + try { + return viewConfigNamesToIds(patch as TableViewConfig, columns) + } catch (error) { + if (error instanceof TableViewValidationError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } } switch (operation) { @@ -106,26 +134,24 @@ export const tableViewsServerTool: BaseServerTool { } ) + 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 +730,43 @@ describe('view config column-reference normalization', () => { ).toEqual([{ field: 'createdAt', direction: 'desc' }]) }) }) + +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('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 dfa4f339b15..7d2df73182b 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -418,6 +418,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 +477,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 +500,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 diff --git a/apps/sim/stores/table/view-pin/store.test.ts b/apps/sim/stores/table/view-pin/store.test.ts new file mode 100644 index 00000000000..c7eefd0c659 --- /dev/null +++ b/apps/sim/stores/table/view-pin/store.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it } from 'vitest' +import { useTableViewPinStore } from '@/stores/table/view-pin/store' + +describe('useTableViewPinStore', () => { + 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' } + ) +)