Skip to content

Commit 6c6da57

Browse files
committed
fix(tables): restore view when returning to chat
1 parent ba35219 commit 6c6da57

7 files changed

Lines changed: 240 additions & 19 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/table', () => ({
9+
Table: () => null,
10+
}))
11+
vi.mock(
12+
'@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session',
13+
() => ({ BrowserSession: () => null })
14+
)
15+
vi.mock(
16+
'@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session',
17+
() => ({ TerminalSession: () => null })
18+
)
19+
20+
import { ResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content'
21+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
22+
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
23+
24+
describe('ResourceContent table view handoff', () => {
25+
let container: HTMLDivElement
26+
let root: Root
27+
28+
beforeEach(() => {
29+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
30+
useTableViewPinStore.getState().reset()
31+
container = document.createElement('div')
32+
root = createRoot(container)
33+
})
34+
35+
afterEach(() => {
36+
act(() => root.unmount())
37+
useTableViewPinStore.getState().reset()
38+
})
39+
40+
function render(resource: MothershipResource) {
41+
act(() => {
42+
root.render(
43+
(
44+
<ResourceContent
45+
workspaceId='workspace-1'
46+
desktopScopeId='chat:chat-1'
47+
resource={resource}
48+
/>
49+
) as ReactNode
50+
)
51+
})
52+
}
53+
54+
it('hands off a saved view that arrives after the embedded table mounts', () => {
55+
const table: MothershipResource = {
56+
type: 'table',
57+
id: 'table-1',
58+
title: 'Invoices',
59+
}
60+
render(table)
61+
expect(useTableViewPinStore.getState().pins['table-1']).toBeUndefined()
62+
63+
render({ ...table, viewId: 'view-edited' })
64+
const pin = useTableViewPinStore.getState().pins['table-1']
65+
expect(pin?.viewId).toBe('view-edited')
66+
67+
render({ ...table, viewId: 'view-edited' })
68+
expect(useTableViewPinStore.getState().pins['table-1']?.seq).toBe(pin?.seq)
69+
})
70+
})

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { useWorkflows } from '@/hooks/queries/workflows'
6262
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
6363
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
6464
import { useExecutionStore } from '@/stores/execution/store'
65+
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
6566
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
6667

6768
const Workflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow'))
@@ -178,6 +179,25 @@ export const ResourceContent = memo(function ResourceContent({
178179
visible = true,
179180
onBrowserOverlayControllerChange,
180181
}: ResourceContentProps) {
182+
const observedTableViewRef = useRef(
183+
resource.type === 'table' ? { tableId: resource.id, viewId: resource.viewId } : null
184+
)
185+
186+
useEffect(() => {
187+
const previous = observedTableViewRef.current
188+
const next =
189+
resource.type === 'table' ? { tableId: resource.id, viewId: resource.viewId } : null
190+
observedTableViewRef.current = next
191+
if (!next?.viewId || (previous?.tableId === next.tableId && previous.viewId === next.viewId)) {
192+
return
193+
}
194+
/**
195+
* `initialViewId` owns the first table adoption. If refreshed chat data
196+
* supplies it later, use the same one-shot handoff as live stream events.
197+
*/
198+
useTableViewPinStore.getState().pin(next.tableId, next.viewId)
199+
}, [resource.id, resource.type, resource.viewId])
200+
181201
const streamFileName = previewSession?.fileName || 'file.md'
182202
const syntheticFile = useMemo(() => {
183203
const ext = getFileExtension(streamFileName)

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ vi.mock('@/lib/api/client/request', async (importOriginal) => ({
4242

4343
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
4444
import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
45+
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
4546
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
4647

4748
const DEDUPED_CHAT_ID = 'chat-the-first-attempt-opened'
@@ -150,13 +151,18 @@ function renderUseChat(): {
150151
* pathname has to match: the hook resets a chat-bound surface back to a fresh
151152
* pending key when it finds itself on the home route.
152153
*/
153-
function renderUseChatInChat(chatId: string): {
154+
function renderUseChatInChat(
155+
chatId: string,
156+
sharedQueryClient: QueryClient = new QueryClient({
157+
defaultOptions: { queries: { retry: false } },
158+
})
159+
): {
154160
getResult: () => ReturnType<typeof useChat>
155161
unmount: () => void
156162
} {
157163
navigationMocks.usePathname.mockReturnValue(`/workspace/ws-1/chat/${chatId}`)
158164
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
159-
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
165+
queryClient = sharedQueryClient
160166
const container = document.createElement('div')
161167
const root = createRoot(container)
162168
mountedRoots.push(root)
@@ -509,4 +515,66 @@ describe('useChat remount send recovery', () => {
509515
// Must NOT have gone to the cross-surface handoff.
510516
expect(MothershipHandoffStorage.consume('ws-1')).toBeNull()
511517
})
518+
519+
it('restores the last edited table view after switching away and back', async () => {
520+
const chatId = 'chat-with-table'
521+
const sharedQueryClient = new QueryClient({
522+
defaultOptions: { queries: { retry: false } },
523+
})
524+
const initialHistory: MothershipChatHistory = {
525+
id: chatId,
526+
title: 'Table chat',
527+
messages: [],
528+
activeStreamId: null,
529+
resources: [{ type: 'table', id: 'table-1', title: 'Invoices' }],
530+
}
531+
sharedQueryClient.setQueryData(mothershipChatKeys.detail(chatId), initialHistory)
532+
533+
const firstSurface = renderUseChatInChat(chatId, sharedQueryClient)
534+
await waitFor(() => firstSurface.getResult().resources.length === 1)
535+
536+
act(() => {
537+
firstSurface.getResult().addResource({
538+
type: 'table',
539+
id: 'table-1',
540+
title: 'Invoices',
541+
viewId: 'view-edited',
542+
})
543+
})
544+
await waitFor(() => firstSurface.getResult().resources[0]?.viewId === 'view-edited')
545+
firstSurface.unmount()
546+
547+
const restoredSurface = renderUseChatInChat(chatId, sharedQueryClient)
548+
await waitFor(() => restoredSurface.getResult().resources.length === 1)
549+
550+
expect(restoredSurface.getResult().resources[0]?.viewId).toBe('view-edited')
551+
})
552+
553+
it('hydrates a table view change when resource identity and title stay the same', async () => {
554+
const chatId = 'chat-with-refetched-view'
555+
const sharedQueryClient = new QueryClient({
556+
defaultOptions: { queries: { retry: false } },
557+
})
558+
const initialHistory: MothershipChatHistory = {
559+
id: chatId,
560+
title: 'Table chat',
561+
messages: [],
562+
activeStreamId: null,
563+
resources: [{ type: 'table', id: 'table-1', title: 'Invoices' }],
564+
}
565+
sharedQueryClient.setQueryData(mothershipChatKeys.detail(chatId), initialHistory)
566+
567+
const surface = renderUseChatInChat(chatId, sharedQueryClient)
568+
await waitFor(() => surface.getResult().resources.length === 1)
569+
570+
act(() => {
571+
sharedQueryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), {
572+
...initialHistory,
573+
resources: [{ type: 'table', id: 'table-1', title: 'Invoices', viewId: 'view-refetched' }],
574+
})
575+
})
576+
577+
await waitFor(() => surface.getResult().resources[0]?.viewId === 'view-refetched')
578+
expect(surface.getResult().resources[0]?.viewId).toBe('view-refetched')
579+
})
512580
})

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -910,10 +910,19 @@ function markMessageStopped(message: PersistedMessage): PersistedMessage {
910910
})
911911
}
912912

913+
function buildChatResourceHydrationKey(resource: MothershipResource): string {
914+
return JSON.stringify([
915+
resource.type,
916+
resource.id,
917+
resource.title,
918+
resource.path ?? null,
919+
resource.viewId ?? null,
920+
resource.executionId ?? null,
921+
])
922+
}
923+
913924
function buildChatHistoryHydrationKey(chatHistory: MothershipChatHistory): string {
914-
const resourceKey = chatHistory.resources
915-
.map((resource) => `${resource.type}:${resource.id}:${resource.title}`)
916-
.join('|')
925+
const resourceKey = chatHistory.resources.map(buildChatResourceHydrationKey).join('|')
917926
const messageKey = chatHistory.messages.map((message) => message.id).join('|')
918927
const streamSnapshot = chatHistory.streamSnapshot
919928
const snapshotKey = streamSnapshot
@@ -1841,6 +1850,28 @@ export function useChat(
18411850
(r) => r.type === resourceUpdate.type && r.id === resourceUpdate.id
18421851
)
18431852
const resource = mergeChatResource(existing, resourceUpdate)
1853+
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
1854+
if (persistChatId && !isEphemeralResource(resource)) {
1855+
queryClient.setQueryData<MothershipChatHistory>(
1856+
mothershipChatKeys.detail(persistChatId),
1857+
(current) => {
1858+
if (!current) return current
1859+
const cached = current.resources.find(
1860+
(item) => item.type === resource.type && item.id === resource.id
1861+
)
1862+
const merged = mergeChatResource(cached, resourceUpdate)
1863+
if (cached === merged) return current
1864+
return {
1865+
...current,
1866+
resources: cached
1867+
? current.resources.map((item) =>
1868+
item.type === resource.type && item.id === resource.id ? merged : item
1869+
)
1870+
: [...current.resources, merged],
1871+
}
1872+
}
1873+
)
1874+
}
18441875
if (existing && resource === existing && resourceUpdate.clearViewId !== true) {
18451876
return false
18461877
}
@@ -1859,12 +1890,11 @@ export function useChat(
18591890
return true
18601891
}
18611892

1862-
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
18631893
const persistenceScopeId = persistChatId ?? pendingChatKeyRef.current
18641894
resourcePersistenceQueue.enqueue(resourceUpdate, persistChatId, existing, persistenceScopeId)
18651895
return existing === undefined
18661896
},
1867-
[resourcePersistenceQueue]
1897+
[queryClient, resourcePersistenceQueue]
18681898
)
18691899

18701900
const removeResource = useCallback(
@@ -2456,9 +2486,8 @@ export function useChat(
24562486
mergedResources.length === resourcesRef.current.length &&
24572487
mergedResources.every(
24582488
(resource, index) =>
2459-
resourcesRef.current[index].type === resource.type &&
2460-
resourcesRef.current[index].id === resource.id &&
2461-
resourcesRef.current[index].title === resource.title
2489+
buildChatResourceHydrationKey(resourcesRef.current[index]) ===
2490+
buildChatResourceHydrationKey(resource)
24622491
)
24632492

24642493
if (mergedResources.length > 0) {

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -383,9 +383,13 @@ export function Table({
383383
const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId })
384384
const deleteViewMutation = useDeleteTableView({ workspaceId, tableId })
385385

386-
/** Resolve the default synchronously so the grid, autosave owner, and menu all
387-
* agree before the URL effect records the adopted view id. */
388-
const { selectedView, defaultView, activeView } = resolveTableViewSelection(views, activeViewId)
386+
/** Resolve the restored or default view synchronously so the grid, autosave
387+
* owner, and menu agree before the URL effect records the adopted view id. */
388+
const { selectedView, defaultView, activeView } = resolveTableViewSelection(
389+
views,
390+
activeViewId,
391+
embedded ? initialViewId : undefined
392+
)
389393
const activeViewConfig = useMemo(
390394
() => resolveTableViewConfig(tableData?.metadata, activeView?.config ?? null),
391395
[tableData?.metadata, activeView?.config]
@@ -662,6 +666,9 @@ export function Table({
662666
return
663667
}
664668

669+
if (activeView && activeViewId === null) {
670+
setTableParams({ view: activeView.id })
671+
}
665672
const nextViewRevision = getTableViewRevision(activeView)
666673
if (
667674
!shouldApplyTableViewRevision(
@@ -678,7 +685,7 @@ export function Table({
678685
if (preserved && preserved.viewId !== nextViewId) {
679686
preservedViewStateRef.current = null
680687
}
681-
if (activeView && (activeViewId === null || activeViewId === ALL_VIEW_PARAM)) {
688+
if (activeView && activeViewId === ALL_VIEW_PARAM) {
682689
setTableParams({ view: activeView.id })
683690
}
684691
const keep = preserved?.viewId === nextViewId ? preserved.keep : undefined

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ const DEFAULT_VIEW: TableViewWire = {
4545
updatedAt: new Date('2026-08-15T01:10:00.000Z'),
4646
}
4747

48+
const PINNED_VIEW: TableViewWire = {
49+
...DEFAULT_VIEW,
50+
id: 'view-pinned',
51+
name: 'Pinned',
52+
isDefault: false,
53+
}
54+
4855
describe('resolveTableViewSelection', () => {
4956
it('makes the persisted default active before its URL id is adopted', () => {
5057
expect(resolveTableViewSelection([DEFAULT_VIEW], null)).toEqual({
@@ -75,6 +82,19 @@ describe('resolveTableViewSelection', () => {
7582
})
7683
})
7784

85+
it('keeps a restored embedded view active while the host URL is absent', () => {
86+
expect(
87+
resolveTableViewSelection([DEFAULT_VIEW, PINNED_VIEW], null, PINNED_VIEW.id).activeView
88+
).toBe(PINNED_VIEW)
89+
})
90+
91+
it('lets an explicit URL selection override the restored embedded view', () => {
92+
expect(
93+
resolveTableViewSelection([DEFAULT_VIEW, PINNED_VIEW], DEFAULT_VIEW.id, PINNED_VIEW.id)
94+
.activeView
95+
).toBe(DEFAULT_VIEW)
96+
})
97+
7898
it('upgrades the legacy All sentinel when a persisted default exists', () => {
7999
expect(resolveTableViewSelection([DEFAULT_VIEW], ALL_VIEW_PARAM).activeView).toBe(DEFAULT_VIEW)
80100
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,33 @@ export function resolveTableViewConfig(
2121
}
2222

2323
/**
24-
* Resolves the persisted default synchronously when the URL has not selected a
25-
* view yet. The URL effect still records that choice, but render-time consumers
26-
* all see the same owner while that update is pending.
24+
* Resolves a restored embedded view, then the persisted default, while the URL
25+
* has no selection. The URL effect still records that choice, but render-time
26+
* consumers all see the same owner while that update is pending.
2727
*/
2828
export function resolveTableViewSelection(
2929
views: TableViewWire[],
30-
activeViewId: string | null
30+
activeViewId: string | null,
31+
restoredViewId?: string
3132
): TableViewSelection {
3233
let selectedView: TableViewWire | null = null
3334
let defaultView: TableViewWire | null = null
35+
let restoredView: TableViewWire | null = null
3436
for (const view of views) {
3537
if (view.id === activeViewId) selectedView = view
3638
if (view.isDefault) defaultView = view
39+
if (view.id === restoredViewId) restoredView = view
3740
}
3841
return {
3942
selectedView,
4043
defaultView,
4144
activeView:
4245
selectedView ??
43-
(activeViewId === null || activeViewId === ALL_VIEW_PARAM ? defaultView : null),
46+
(activeViewId === null
47+
? (restoredView ?? defaultView)
48+
: activeViewId === ALL_VIEW_PARAM
49+
? defaultView
50+
: null),
4451
}
4552
}
4653

0 commit comments

Comments
 (0)