Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cd339c8
feat(copilot): add create_table_view and edit_table_view
j15z Aug 27, 2026
b1cd4a9
fix(copilot): address review findings on table view tools
j15z Aug 27, 2026
ec72d49
Merge remote-tracking branch 'origin/staging' into feat/let-mothershi…
j15z Aug 27, 2026
9a6b0ce
refactor(copilot): drop the direct view tools, pin views through tabl…
j15z Aug 27, 2026
6adf211
chore(copilot): sync table view update semantics
j15z Aug 29, 2026
c59ebc2
Merge remote-tracking branch 'origin/staging' into feat/let-mothershi…
j15z Sep 1, 2026
3aa7dbf
fix(copilot): sync table view sort item schema
j15z Sep 1, 2026
f2fc678
Merge remote-tracking branch 'origin/staging' into feat/let-mothershi…
j15z Sep 1, 2026
3e6eb75
fix(copilot): persist table view pin updates
j15z Sep 1, 2026
838c936
fix(tables): reconcile agent view pins
j15z Sep 1, 2026
dac94a3
fix(copilot): type resource update directives
j15z Sep 1, 2026
e2d2284
fix(tables): serialize default view demotions
j15z Sep 1, 2026
0a0b72a
fix(copilot): preserve view pin clear requests
j15z Sep 1, 2026
29341bf
fix(copilot): serialize resource view updates
j15z Sep 1, 2026
861c966
fix(copilot): close resource persistence races
j15z Sep 1, 2026
905fc86
fix(copilot): retain resource removal intent
j15z Sep 1, 2026
ba35219
fix: isolate copilot resource persistence by chat
j15z Sep 1, 2026
6c6da57
fix(tables): restore view when returning to chat
j15z Sep 2, 2026
390e1b3
fix(copilot): bound resource-write locks and repair reorder persistence
waleedlatif1 Sep 3, 2026
081b5cf
fix(copilot): hold a reorder for pending deletes too
waleedlatif1 Sep 3, 2026
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
194 changes: 102 additions & 92 deletions apps/sim/app/api/copilot/chat/resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ import {
createNotFoundResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import type { ChatResource } from '@/lib/copilot/resources/persistence'
import {
type ChatResource,
serializeChatResourceWrite,
setChatResourceTxTimeouts,
} from '@/lib/copilot/resources/persistence'
import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types'
import {
canonicalizeDesktopSessionResource,
GENERIC_RESOURCE_TITLES,
mergeChatResource,
reorderStoredChatResources,
sanitizeChatResources,
} from '@/lib/copilot/resources/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand All @@ -43,59 +49,56 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
)
if (!parsed.success) return parsed.response
const { chatId, resource: requestedResource } = parsed.data.body
const { chatId, resource: requestedResource, clearViewId } = parsed.data.body
Comment thread
j15z marked this conversation as resolved.
const resource = canonicalizeDesktopSessionResource(requestedResource)
const resourceUpdate: MothershipResourceUpdate =
clearViewId === true ? { ...resource, clearViewId: true } : resource

// Ephemeral UI tab (client does not POST this; guard for old clients / bugs).
if (resource.id === 'streaming-file') {
return NextResponse.json({ success: true })
}

const [chat] = await db
.select({ resources: copilotChats.resources })
.from(copilotChats)
.where(
and(
const merged = await serializeChatResourceWrite(chatId, () =>
db.transaction(async (tx) => {
await setChatResourceTxTimeouts(tx)
const scope = and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)
.limit(1)
const [chat] = await tx
.select({ resources: copilotChats.resources })
.from(copilotChats)
.where(scope)
.for('update')
.limit(1)

if (!chat) {
return createNotFoundResponse('Chat not found or unauthorized')
}
if (!chat) return null

const existing = sanitizeChatResources(
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
)
const key = `${resource.type}:${resource.id}`
const prev = existing.find((r) => `${r.type}:${r.id}` === key)

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
const existing = sanitizeChatResources(
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
)
} else {
merged = existing
}
} else {
merged = [...existing, resource]
}
const key = `${resource.type}:${resource.id}`
const prev = existing.find((r) => `${r.type}:${r.id}` === key)
const next: ChatResource[] = prev
? existing.map((r) =>
`${r.type}:${r.id}` === key ? mergeChatResource(r, resourceUpdate) : r
)
: [...existing, mergeChatResource(undefined, resourceUpdate)]

await tx
.update(copilotChats)
.set({ resources: sql`${JSON.stringify(next)}::jsonb`, updatedAt: new Date() })
.where(scope)

return next
})
)

await db
.update(copilotChats)
.set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() })
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)
if (!merged) {
return createNotFoundResponse('Chat not found or unauthorized')
}

logger.info('Added resource to chat', { chatId, resource })

Expand Down Expand Up @@ -125,44 +128,45 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
if (!parsed.success) return parsed.response
const { chatId, resources: newOrder } = parsed.data.body

const [chat] = await db
.select({ resources: copilotChats.resources })
.from(copilotChats)
.where(
and(
const canonicalOrder = await serializeChatResourceWrite(chatId, () =>
db.transaction(async (tx): Promise<ChatResource[] | null | undefined> => {
await setChatResourceTxTimeouts(tx)
const scope = and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)
.limit(1)
const [chat] = await tx
.select({ resources: copilotChats.resources })
.from(copilotChats)
.where(scope)
.for('update')
.limit(1)

if (!chat) {
return createNotFoundResponse('Chat not found or unauthorized')
}
if (!chat) return undefined

const existing = sanitizeChatResources(
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
)
const next = reorderStoredChatResources(existing, newOrder)
if (!next) return null

await tx
.update(copilotChats)
.set({ resources: sql`${JSON.stringify(next)}::jsonb`, updatedAt: new Date() })
.where(scope)

const existing = sanitizeChatResources(
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
return next
})
)
const canonicalOrder = sanitizeChatResources(newOrder)
const existingKeys = new Set(existing.map((r) => `${r.type}:${r.id}`))
const newKeys = new Set(canonicalOrder.map((r) => `${r.type}:${r.id}`))

if (existingKeys.size !== newKeys.size || ![...existingKeys].every((k) => newKeys.has(k))) {
if (canonicalOrder === undefined) {
return createNotFoundResponse('Chat not found or unauthorized')
}
if (!canonicalOrder) {
return createBadRequestResponse('Reordered resources must match existing resources')
}

await db
.update(copilotChats)
.set({ resources: sql`${JSON.stringify(canonicalOrder)}::jsonb`, updatedAt: new Date() })
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)

logger.info('Reordered resources for chat', { chatId, count: canonicalOrder.length })

return NextResponse.json({ success: true, resources: canonicalOrder })
Expand Down Expand Up @@ -191,39 +195,45 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => {
if (!parsed.success) return parsed.response
const { chatId, resourceType, resourceId } = parsed.data.body

// Old builds could persist an inner browser/terminal tab id. Closing the
// singleton panel removes every legacy row of that type so it cannot be
// canonicalized back into view on the next hydration.
const removePredicate =
resourceType === 'browser' || resourceType === 'terminal'
? sql`elem->>'type' = ${resourceType}`
: sql`elem->>'type' = ${resourceType} AND elem->>'id' = ${resourceId}`

const [updated] = await db
.update(copilotChats)
.set({
resources: sql`COALESCE((
SELECT jsonb_agg(elem)
FROM jsonb_array_elements(${copilotChats.resources}) elem
WHERE NOT (${removePredicate})
), '[]'::jsonb)`,
updatedAt: new Date(),
})
.where(
and(
const merged = await serializeChatResourceWrite(chatId, () =>
db.transaction(async (tx) => {
await setChatResourceTxTimeouts(tx)
const scope = and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)
.returning({ resources: copilotChats.resources })
const [chat] = await tx
.select({ resources: copilotChats.resources })
.from(copilotChats)
.where(scope)
.for('update')
.limit(1)

if (!updated) {
if (!chat) return null

const existing = sanitizeChatResources(
Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
)
const removeAllOfType = resourceType === 'browser' || resourceType === 'terminal'
const next = existing.filter(
(resource) =>
resource.type !== resourceType || (!removeAllOfType && resource.id !== resourceId)
)

await tx
.update(copilotChats)
.set({ resources: sql`${JSON.stringify(next)}::jsonb`, updatedAt: new Date() })
.where(scope)

return next
})
)

if (!merged) {
return createNotFoundResponse('Chat not found or unauthorized')
}

const merged = Array.isArray(updated.resources) ? (updated.resources as ChatResource[]) : []

logger.info('Removed resource from chat', { chatId, resourceType, resourceId })

return NextResponse.json({ success: true, resources: merged })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/table', () => ({
Table: () => null,
}))
vi.mock(
'@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session',
() => ({ BrowserSession: () => null })
)
vi.mock(
'@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session',
() => ({ TerminalSession: () => null })
)

import { ResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { useTableViewPinStore } from '@/stores/table/view-pin/store'

describe('ResourceContent table view handoff', () => {
let container: HTMLDivElement
let root: Root

beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
useTableViewPinStore.getState().reset()
container = document.createElement('div')
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
useTableViewPinStore.getState().reset()
})

function render(resource: MothershipResource) {
act(() => {
root.render(
(
<ResourceContent
workspaceId='workspace-1'
desktopScopeId='chat:chat-1'
resource={resource}
/>
) as ReactNode
)
})
}

it('hands off a saved view that arrives after the embedded table mounts', () => {
const table: MothershipResource = {
type: 'table',
id: 'table-1',
title: 'Invoices',
}
render(table)
expect(useTableViewPinStore.getState().pins['table-1']).toBeUndefined()

render({ ...table, viewId: 'view-edited' })
const pin = useTableViewPinStore.getState().pins['table-1']
expect(pin?.viewId).toBe('view-edited')

render({ ...table, viewId: 'view-edited' })
expect(useTableViewPinStore.getState().pins['table-1']?.seq).toBe(pin?.seq)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useExecutionStore } from '@/stores/execution/store'
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'

const Workflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow'))
Expand Down Expand Up @@ -178,6 +179,25 @@ export const ResourceContent = memo(function ResourceContent({
visible = true,
onBrowserOverlayControllerChange,
}: ResourceContentProps) {
const observedTableViewRef = useRef(
resource.type === 'table' ? { tableId: resource.id, viewId: resource.viewId } : null
)

useEffect(() => {
const previous = observedTableViewRef.current
const next =
resource.type === 'table' ? { tableId: resource.id, viewId: resource.viewId } : null
observedTableViewRef.current = next
if (!next?.viewId || (previous?.tableId === next.tableId && previous.viewId === next.viewId)) {
return
}
/**
* `initialViewId` owns the first table adoption. If refreshed chat data
* supplies it later, use the same one-shot handoff as live stream events.
*/
useTableViewPinStore.getState().pin(next.tableId, next.viewId)
}, [resource.id, resource.type, resource.viewId])

const streamFileName = previewSession?.fileName || 'file.md'
const syntheticFile = useMemo(() => {
const ext = getFileExtension(streamFileName)
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
Loading
Loading