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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 11 additions & 15 deletions apps/sim/app/api/copilot/chat/resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Comment thread
j15z marked this conversation as resolved.
useTableViewPinStore.getState().pin(resource.id, pinnedViewId)
}
invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id)

if (!shouldSuppressFileResourceActivation) onResourceEvent?.(resource.id)
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -2339,6 +2342,7 @@ export function useChat(
setTransportIdle()
setResources([])
setActiveResourceId(null)
useTableViewPinStore.getState().reset()
Comment thread
j15z marked this conversation as resolved.
pendingPersistResourceKeysRef.current.clear()
inFlightResourceAddsRef.current.clear()
reorderNeededAfterFlushRef.current = false
Expand Down
23 changes: 23 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/sim/hooks/queries/mothership-chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
}
}

Expand Down
26 changes: 13 additions & 13 deletions apps/sim/lib/api/contracts/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,19 @@ export type RenameCopilotChatBody = z.input<typeof renameCopilotChatBodySchema>

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<typeof addCopilotChatResourceBodySchema>

Expand All @@ -119,13 +124,7 @@ export type RemoveCopilotChatResourceBody = z.input<typeof removeCopilotChatReso

export const reorderCopilotChatResourcesBodySchema = z.object({
chatId: z.string(),
resources: z.array(
z.object({
type: copilotResourceTypeSchema,
id: z.string(),
title: z.string(),
})
),
resources: z.array(copilotChatResourceItemSchema),
})
export type ReorderCopilotChatResourcesBody = z.input<typeof reorderCopilotChatResourcesBodySchema>

Expand Down Expand Up @@ -423,6 +422,7 @@ const copilotChatResourceSchema = z.object({
type: copilotResourceTypeSchema,
id: z.string(),
title: z.string(),
viewId: z.string().optional(),
})

const copilotAvailableModelSchema = z.object({
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/api/contracts/mothership-chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = {
type: {
type: 'string',
},
viewId: {
type: 'string',
},
},
required: ['type', 'id'],
type: 'object',
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/copilot/generated/mothership-stream-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export interface MothershipStreamV1ResourceDescriptor {
id: string
title?: string
type: string
viewId?: string
}
export interface MothershipStreamV1ResourceRemoveEventEnvelope {
payload: MothershipStreamV1ResourceRemovePayload
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/copilot/generated/tool-catalog-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5786,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.',
},
Expand All @@ -5807,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.',
},
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/copilot/generated/tool-schemas-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5718,7 +5718,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
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.',
},
Expand All @@ -5741,7 +5741,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
'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.',
},
Expand Down
Loading
Loading