Skip to content

Commit cd339c8

Browse files
committed
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.
1 parent 2fb768d commit cd339c8

38 files changed

Lines changed: 1434 additions & 59 deletions

apps/sim/app/api/copilot/chat/resources/route.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import type { ChatResource } from '@/lib/copilot/resources/persistence'
2020
import {
2121
canonicalizeDesktopSessionResource,
22-
GENERIC_RESOURCE_TITLES,
22+
mergeChatResource,
2323
sanitizeChatResources,
2424
} from '@/lib/copilot/resources/types'
2525
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -73,18 +73,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
7373
const key = `${resource.type}:${resource.id}`
7474
const prev = existing.find((r) => `${r.type}:${r.id}` === key)
7575

76-
let merged: ChatResource[]
77-
if (prev) {
78-
if (GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(resource.title)) {
79-
merged = existing.map((r) =>
80-
`${r.type}:${r.id}` === key ? { ...r, title: resource.title } : r
81-
)
82-
} else {
83-
merged = existing
84-
}
85-
} else {
86-
merged = [...existing, resource]
87-
}
76+
const merged: ChatResource[] = prev
77+
? existing.map((r) => (`${r.type}:${r.id}` === key ? mergeChatResource(r, resource) : r))
78+
: [...existing, resource]
8879

8980
await db
9081
.update(copilotChats)

apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ const TOOL_ICONS: Record<string, IconComponent> = {
5858
search_knowledge_base: Database,
5959
table: TableIcon,
6060
query_user_table: TableIcon,
61+
create_table_view: TableIcon,
62+
edit_table_view: TableIcon,
6163
job: Calendar,
6264
agent: AgentIcon,
6365
custom_tool: Wrench,

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,9 @@ const RESOURCE_INVALIDATORS: Record<
300300
table: (qc, _wId, id) => {
301301
qc.invalidateQueries({ queryKey: tableKeys.lists() })
302302
qc.invalidateQueries({ queryKey: tableKeys.detail(id) })
303+
// A view the agent just created must be in the list before the embedded
304+
// table can switch to it; see the view-pin store.
305+
qc.invalidateQueries({ queryKey: tableKeys.views(id) })
303306
},
304307
file: (qc, wId, id) => {
305308
qc.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })

apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session
2020
import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event'
2121
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
2222
import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers'
23+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
24+
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
2325

2426
function removeEvent(type: 'workflow' | 'file', id: string): PersistedStreamEventEnvelope {
2527
return {
@@ -105,3 +107,80 @@ describe('handleResourceEvent removal', () => {
105107
expect(onResourceEvent).toHaveBeenCalledWith('browser-session')
106108
})
107109
})
110+
111+
function tableUpsertEvent(id: string, viewId?: string): PersistedStreamEventEnvelope {
112+
return {
113+
type: 'resource',
114+
v: 1,
115+
seq: 1,
116+
ts: '',
117+
stream: { streamId: 's', cursor: '1' },
118+
payload: {
119+
op: 'upsert',
120+
resource: { type: 'table', id, title: 'Invoices', ...(viewId ? { viewId } : {}) },
121+
},
122+
} as PersistedStreamEventEnvelope
123+
}
124+
125+
describe('handleResourceEvent saved-view pins', () => {
126+
beforeEach(() => {
127+
vi.clearAllMocks()
128+
useTableViewPinStore.getState().reset()
129+
})
130+
131+
it('opens a closed table on the view and leaves a pin for the table to consume', () => {
132+
const onResourceEvent = vi.fn()
133+
const deps = makeStreamLoopDeps({ onResourceEventRef: { current: onResourceEvent } })
134+
const ctx = { deps } as StreamLoopContext
135+
136+
handleResourceEvent(ctx, tableUpsertEvent('tbl-1', 'view-1'))
137+
138+
expect(deps.addResource).toHaveBeenCalledWith({
139+
type: 'table',
140+
id: 'tbl-1',
141+
title: 'Invoices',
142+
viewId: 'view-1',
143+
})
144+
expect(deps.setResources).not.toHaveBeenCalled()
145+
expect(useTableViewPinStore.getState().pins['tbl-1']?.viewId).toBe('view-1')
146+
expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith(
147+
deps.queryClient,
148+
'ws-1',
149+
'table',
150+
'tbl-1'
151+
)
152+
expect(onResourceEvent).toHaveBeenCalledWith('tbl-1')
153+
})
154+
155+
it('moves the pin on an already-open table so a remount and the live grid both follow', () => {
156+
const open: MothershipResource = {
157+
type: 'table',
158+
id: 'tbl-1',
159+
title: 'Invoices',
160+
viewId: 'view-1',
161+
}
162+
const deps = makeStreamLoopDeps({
163+
addResource: vi.fn(() => false),
164+
resourcesRef: { current: [open] },
165+
})
166+
const ctx = { deps } as StreamLoopContext
167+
168+
handleResourceEvent(ctx, tableUpsertEvent('tbl-1', 'view-2'))
169+
170+
const updater = (deps.setResources as ReturnType<typeof vi.fn>).mock.calls[0][0] as (
171+
current: MothershipResource[]
172+
) => MothershipResource[]
173+
expect(updater([open])).toEqual([{ ...open, viewId: 'view-2' }])
174+
expect(useTableViewPinStore.getState().pins['tbl-1']?.viewId).toBe('view-2')
175+
})
176+
177+
it('ignores a pin on anything but a table and leaves unpinned tables alone', () => {
178+
const deps = makeStreamLoopDeps({ addResource: vi.fn(() => false) })
179+
const ctx = { deps } as StreamLoopContext
180+
181+
handleResourceEvent(ctx, tableUpsertEvent('tbl-1'))
182+
183+
expect(deps.setResources).not.toHaveBeenCalled()
184+
expect(useTableViewPinStore.getState().pins['tbl-1']).toBeUndefined()
185+
})
186+
})

apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
1414
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
1515
import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache'
16+
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
1617
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1718

1819
type ResourceEvent = Extract<
@@ -44,11 +45,20 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
4445
} = ctx.deps
4546
const onResourceEvent = onResourceEventRef.current
4647
const payload = parsed.payload
48+
// A saved view the agent just created or edited: the table opens on it, and
49+
// an already-open table switches to it.
50+
const pinnedViewId =
51+
payload.resource.type === 'table' &&
52+
typeof payload.resource.viewId === 'string' &&
53+
payload.resource.viewId.trim()
54+
? payload.resource.viewId
55+
: undefined
4756
const resource = canonicalizeDesktopSessionResource({
4857
type: payload.resource.type as MothershipResourceType,
4958
id: payload.resource.id,
5059
title:
5160
typeof payload.resource.title === 'string' ? payload.resource.title : payload.resource.id,
61+
...(pinnedViewId ? { viewId: pinnedViewId } : {}),
5262
})
5363

5464
if (payload.op === MothershipStreamV1ResourceOp.remove) {
@@ -111,6 +121,21 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
111121
completedPreviewResourceHandoffRef.current.delete(resource.id)
112122
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
113123
}
124+
if (pinnedViewId) {
125+
if (!wasAdded) {
126+
// The tab already exists: carry the newest pin so a remount adopts it.
127+
setResources((current) =>
128+
current.some((r) => r.type === 'table' && r.id === resource.id && r.viewId !== pinnedViewId)
129+
? current.map((r) =>
130+
r.type === 'table' && r.id === resource.id ? { ...r, viewId: pinnedViewId } : r
131+
)
132+
: current
133+
)
134+
}
135+
// Consumed by the embedded table once its views list carries the view —
136+
// which may be after the refetch below lands, or after the tab first opens.
137+
useTableViewPinStore.getState().pin(resource.id, pinnedViewId)
138+
}
114139
invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id)
115140

116141
if (!shouldSuppressFileResourceActivation) onResourceEvent?.(resource.id)

apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export function resolveIntegrationToolDisplayTitle(tool: {
221221
* client resolves the id against the workflow registry.
222222
*/
223223
const TABLE_SCOPED_TOOL_IDS = new Set<string>([
224+
'create_table_view',
224225
'table_automations',
225226
'table_columns',
226227
'table_enrichments',

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { useInlineRename } from '@/hooks/use-inline-rename'
6868
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
6969
import { useLogDetailsUIStore } from '@/stores/logs/store'
7070
import type { DeletedRowSnapshot } from '@/stores/table/types'
71+
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
7172
import {
7273
type ColumnConfig,
7374
ColumnConfigSidebar,
@@ -701,6 +702,28 @@ export function Table({
701702
tableData?.metadata,
702703
])
703704

705+
/**
706+
* A view the agent just created or edited (see the view-pin store). Applied
707+
* only once the views list carries it — the pin arrives ahead of the list
708+
* refetch, and writing the URL earlier would name a view the effect above
709+
* resolves to nothing and treats as dead. First adoption is left to that
710+
* effect (it honours `initialViewId` itself); a pin that turns out to be the
711+
* view already applied is consumed without a URL write.
712+
*/
713+
const viewPin = useTableViewPinStore((state) => state.pins[tableId])
714+
const consumeViewPin = useTableViewPinStore((state) => state.consume)
715+
useEffect(() => {
716+
if (!embedded || !viewPin) return
717+
if (appliedViewRevisionRef.current === undefined) return
718+
if (!views.some((view) => view.id === viewPin.viewId)) return
719+
consumeViewPin(tableId, viewPin.seq)
720+
if (activeViewId === viewPin.viewId || appliedViewRevisionRef.current.id === viewPin.viewId) {
721+
return
722+
}
723+
preservedViewStateRef.current = null
724+
setTableParams({ view: viewPin.viewId })
725+
}, [embedded, viewPin, views, activeViewId, tableId, consumeViewPin, setTableParams])
726+
704727
/**
705728
* Live state pruned the same way `pruneViewConfig` prunes the stored config on
706729
* read. Without this, deleting a hidden or sorted column leaves the local ids

apps/sim/hooks/queries/mothership-chats.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ function parseResource(value: unknown, context: string): MothershipResource {
138138
type: value.type,
139139
id: value.id,
140140
title: value.title,
141+
...(typeof value.viewId === 'string' && value.viewId ? { viewId: value.viewId } : {}),
141142
}
142143
}
143144

apps/sim/lib/api/contracts/copilot.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ export const addCopilotChatResourceBodySchema = z.object({
106106
// Matches the bound the chat-send path enforces.
107107
id: requiredFieldSchema('resource.id cannot be empty'),
108108
title: z.string(),
109+
// Saved view a table tab is pinned to (type "table" only).
110+
viewId: z.string().min(1).optional(),
109111
}),
110112
})
111113
export type AddCopilotChatResourceBody = z.input<typeof addCopilotChatResourceBodySchema>
@@ -423,6 +425,7 @@ const copilotChatResourceSchema = z.object({
423425
type: copilotResourceTypeSchema,
424426
id: z.string(),
425427
title: z.string(),
428+
viewId: z.string().optional(),
426429
})
427430

428431
const copilotAvailableModelSchema = z.object({

apps/sim/lib/api/contracts/mothership-chats.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ const mothershipChatResourceItemSchema = z.object({
201201
type: z.string(),
202202
id: z.string(),
203203
title: z.string(),
204+
/** Saved view a table tab is pinned to (type "table" only); dropped here, it would be lost on reorder. */
205+
viewId: z.string().min(1).optional(),
204206
})
205207

206208
const mothershipChatResourcesResponseSchema = z.object({

0 commit comments

Comments
 (0)