diff --git a/config/e2e-typecheck-baseline.txt b/config/e2e-typecheck-baseline.txt index e22e466ed32..f21ddac1c36 100644 --- a/config/e2e-typecheck-baseline.txt +++ b/config/e2e-typecheck-baseline.txt @@ -96,7 +96,6 @@ 1 tests/e2e/tab-rename-paste-ownership.spec.ts 1 tests/e2e/tab-rename.spec.ts 3 tests/e2e/tasks-page.spec.ts -5 tests/e2e/terminal-account-restart.unit.test.ts 1 tests/e2e/terminal-codex-cursor-jitter-repro.spec.ts 4 tests/e2e/terminal-codex-hidden-startup-background.spec.ts 1 tests/e2e/terminal-codex-local-typing-latency.spec.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index e84e251c5e5..4a3688c818c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -3,6 +3,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' +import type { AppState } from '@/store/types' export type StoreState = { activeWorktreeId: string | null @@ -20,6 +21,8 @@ export type StoreState = { > ptyIdsByTabId?: Record terminalLayoutsByTabId?: Record + /** Pty id → the agent its pane restarts as once the account change lands. */ + pendingPaneAccountRestarts?: AppState['pendingPaneAccountRestarts'] unreadTerminalTabs?: Record deleteStateByWorktreeId?: Record worktreesByRepo: Record< diff --git a/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts b/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts index 3413f6f3a73..1be3e79a51c 100644 --- a/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts +++ b/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts @@ -17,10 +17,23 @@ import type { LayoutCommandResult } from '../../../shared/workspace-layout/workspace-layout-command-types' import type { WorkspaceLayoutModel } from '../../../shared/workspace-layout/workspace-layout-model' -import { publishWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { + publishWorkspaceLayout, + type PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +import { + diffPublishedLayouts, + indexPublishedLayout, + type WorkspaceLayoutRecordChange +} from '../../../shared/workspace-layout/workspace-layout-records' import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { layoutSnapshotFrame, publishedLayoutsOf } from './main-loaded-layout-frames.test-fixture' +import { throwLayoutFrameApplyErrors } from './workspace-layout-frame-apply-check.test-fixture' +import { + layoutFrameApplyObserver, + observeLayoutFrameApply +} from './workspace-layout-frame-apply-observer' import type * as LayoutClient from './runtime-layout-client' import type * as RpcClient from './runtime-rpc-client' @@ -125,6 +138,23 @@ function emit(frame: WorkspaceLayoutChangeFrame): void { } } +const TEST_EPOCH = 'layout-apply-test-epoch' + +/** One workspace's records an operation changed, as main's stream publishes them. */ +function recordChanges( + key: string, + before: PublishedWorkspaceLayout | undefined, + after: PublishedWorkspaceLayout | undefined +): WorkspaceLayoutRecordChange[] { + if (!after) { + return [{ key, change: 'removed' }] + } + const change = before + ? diffPublishedLayouts(key, indexPublishedLayout(before), indexPublishedLayout(after), after) + : { key, change: 'layout' as const, layout: after } + return change ? [change] : [] +} + /** Main's apply without its routing: no host partitions, `host_not_owned`, forced closes, or a * frame position on refusals. */ function applyToMainModel(command: LayoutCommand): LayoutApplyReply { @@ -144,13 +174,19 @@ function applyToMainModel(command: LayoutCommand): LayoutApplyReply { state.seq += 1 const seq = state.seq const key = command.workspace + const before = model.workspaces[key] const workspace = applied.model.workspaces[key] - // Main publishes before it replies. - emit( - workspace - ? { type: 'workspace', key, layout: publishWorkspaceLayout(workspace, model.hostId), seq } - : { type: 'removed', key, seq } - ) + // Main publishes the operation's records before it replies. + emit({ + type: 'records', + epoch: TEST_EPOCH, + seq, + workspaces: recordChanges( + key, + before && publishWorkspaceLayout(before, model.hostId), + workspace && publishWorkspaceLayout(workspace, model.hostId) + ) + }) return { ok: true, result: applied.result, frameSeq: seq } } @@ -284,6 +320,11 @@ export function runtimeLayoutClientWithTestFrames(actual: LayoutClientModule): L } const sink: FrameSink = { onFrame, onPosition } state.sinks.add(sink) + // Every flush of the window's frames must show what the full build shows; a test's own + // installed check keeps reporting it. + const previousCheck = layoutFrameApplyObserver() + const installedCheck = previousCheck ?? throwLayoutFrameApplyErrors + observeLayoutFrameApply(installedCheck) onStatus({ state: 'connecting' }) queueMicrotask(() => { if (!state.sinks.has(sink)) { @@ -296,6 +337,10 @@ export function runtimeLayoutClientWithTestFrames(actual: LayoutClientModule): L return { close: () => { state.sinks.delete(sink) + // A check installed after this one (another stream or test) stays in place. + if (layoutFrameApplyObserver() === installedCheck) { + observeLayoutFrameApply(previousCheck) + } onStatus({ state: 'closed' }) } } diff --git a/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts b/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts index c25b8c345fa..d5382b3661e 100644 --- a/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts +++ b/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts @@ -10,6 +10,10 @@ import { publishWorkspaceLayout, type PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { + diffPublishedLayouts, + indexPublishedLayout +} from '../../../shared/workspace-layout/workspace-layout-records' import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' /** The model main loads from one stored partition, with the ids it mints at load. */ @@ -51,3 +55,48 @@ export function layoutSnapshotFrame( ...(seq === undefined ? {} : { seq }) } } + +/** + * Main's frames as a layout-owner host sends them: a snapshot, then for each later layout one + * records frame of the records that changed since the layout it last sent for that workspace. + */ +export function mainLayoutFrameSender(): { + snapshot: ( + layouts: Record, + seq?: number + ) => WorkspaceLayoutChangeFrame + records: ( + key: string, + layout: PublishedWorkspaceLayout, + seq?: number + ) => WorkspaceLayoutChangeFrame +} { + let sent = new Map() + let position = 0 + return { + snapshot: (layouts, seq) => { + sent = new Map(Object.entries(layouts).map(([key, layout]) => [key, structuredClone(layout)])) + position = seq ?? position + return layoutSnapshotFrame(layouts, seq) + }, + records: (key, layout, seq) => { + const before = sent.get(key) + sent.set(key, structuredClone(layout)) + position = seq ?? position + 1 + const change = before + ? diffPublishedLayouts( + key, + indexPublishedLayout(before), + indexPublishedLayout(layout), + layout + ) + : { key, change: 'layout' as const, layout } + return { + type: 'records', + epoch: 'main-epoch', + seq: position, + workspaces: change ? [change] : [] + } + } + } +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-apply-check.test-fixture.ts b/src/renderer/src/runtime/workspace-layout-frame-apply-check.test-fixture.ts new file mode 100644 index 00000000000..cfad49227e3 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-apply-check.test-fixture.ts @@ -0,0 +1,162 @@ +// The window's frame-apply check for one test's life: after every flush, the store must show what +// the full build (the window's view and facts read back through its save and the Loader) shows +// for the same cache and the store as the flush found it. Mirrors main's model check. + +import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' +import { isDrawnGroupLayoutHeld, isDrawnTabHeld } from './layout-owner-drawn-tab-holds' +import { isTabPaneTreeHeld } from './layout-owner-pane-intents' +import { + observeLayoutFrameApply, + type LayoutFrameApplyObservation +} from './workspace-layout-frame-apply-observer' +import { fullLayoutFrameBuildPatch } from './workspace-layout-frame-builds' +import type { LayoutFrameSlices } from '@/store/workspace-layout-frame-slices' +import type { AppState } from '@/store/types' + +const FRAME_SLICES: readonly (keyof LayoutFrameSlices)[] = [ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'activeTabIdByWorktree', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'activeGroupIdByWorktree', + 'layoutByWorktree', + 'openFiles', + 'editorDrafts', + 'markdownFrontmatterVisible', + 'activeFileIdByWorktree', + 'browserTabsByWorktree', + 'browserPagesByWorkspace', + 'remoteBrowserPageHandlesByPageId', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree' +] + +/** The slices' records of `keys` alone: each workspace's entries, and its tabs', files' and pages'. */ +function recordsOf(state: AppState, keys: readonly string[]): Record { + const keySet = new Set(keys) + const byKey = (map: Record): Record => + Object.fromEntries(Object.entries(map).filter(([key]) => keySet.has(key))) + const tabIds = new Set(keys.flatMap((key) => (state.tabsByWorktree[key] ?? []).map((t) => t.id))) + const files = state.openFiles.filter((file) => keySet.has(file.worktreeId)) + const fileIds = new Set(files.map((file) => file.id)) + const browserTabIds = new Set( + keys.flatMap((key) => (state.browserTabsByWorktree[key] ?? []).map((tab) => tab.id)) + ) + const pageIds = new Set( + [...browserTabIds].flatMap((id) => (state.browserPagesByWorkspace[id] ?? []).map((p) => p.id)) + ) + const only = (map: Record, ids: ReadonlySet): Record => + Object.fromEntries(Object.entries(map).filter(([id]) => ids.has(id))) + return { + tabsByWorktree: byKey(state.tabsByWorktree), + terminalLayoutsByTabId: only(state.terminalLayoutsByTabId, tabIds), + activeTabIdByWorktree: byKey(state.activeTabIdByWorktree), + unifiedTabsByWorktree: byKey(state.unifiedTabsByWorktree), + groupsByWorktree: byKey(state.groupsByWorktree), + activeGroupIdByWorktree: byKey(state.activeGroupIdByWorktree), + layoutByWorktree: byKey(state.layoutByWorktree), + openFiles: files, + editorDrafts: only(state.editorDrafts, fileIds), + markdownFrontmatterVisible: only(state.markdownFrontmatterVisible, fileIds), + activeFileIdByWorktree: byKey(state.activeFileIdByWorktree), + browserTabsByWorktree: byKey(state.browserTabsByWorktree), + browserPagesByWorkspace: only(state.browserPagesByWorkspace, browserTabIds), + remoteBrowserPageHandlesByPageId: only(state.remoteBrowserPageHandlesByPageId, pageIds), + activeBrowserTabIdByWorktree: byKey(state.activeBrowserTabIdByWorktree), + activeTabTypeByWorktree: byKey(state.activeTabTypeByWorktree) + } +} + +/** + * Empty when the flush left the store as the full build of its workspaces leaves it. A workspace + * the flush built in full ran the reference itself (and a second run mints other ids for a new + * page), so a flush that built one in full is checked on its other workspaces' records. + */ +export function layoutFrameApplyErrors({ + before, + after, + cache, + changed, + fullBuilt, + rowFieldsTaken +}: LayoutFrameApplyObservation): string[] { + const keys = changed.filter((key) => key in cache && !fullBuilt.includes(key)) + if (keys.length === 0) { + return [] + } + const built: AppState = { + ...before, + ...fullLayoutFrameBuildPatch(before, cache, keys, { + keepsTerminalLayout: isTabPaneTreeHeld, + newRowFields: (tabId) => rowFieldsTaken.get(tabId) ?? null, + held: { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld } + }) + } + const whole = fullBuilt.length === 0 + const expected: Record = whole ? { ...built } : recordsOf(built, keys) + const shown: Record = whole ? { ...after } : recordsOf(after, keys) + return FRAME_SLICES.flatMap((slice) => + stableJson(shown[slice]) === stableJson(expected[slice]) + ? [] + : [firstDifference(slice, shown[slice], expected[slice])] + ) +} + +/** For a shared transport with no test of its own to report to: the flush itself fails. */ +export function throwLayoutFrameApplyErrors(observation: LayoutFrameApplyObservation): void { + const errors = layoutFrameApplyErrors(observation) + if (errors.length > 0) { + throw new Error(`layout frame apply differs from the full build:\n${errors.join('\n')}`) + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** The first path where the store and the full build differ, with both values there. */ +function firstDifference(path: string, shown: unknown, built: unknown): string { + if (isObject(shown) && isObject(built)) { + for (const key of new Set([...Object.keys(shown), ...Object.keys(built)])) { + if (stableJson(shown[key]) !== stableJson(built[key])) { + return firstDifference(`${path}.${key}`, shown[key], built[key]) + } + } + } + return `${path}: store ${stableJson(shown)} != full build ${stableJson(built)}` +} + +export function installLayoutFrameApplyCheck(): { + /** Every mismatch found, flush by flush. */ + failures: string[] + /** Workspaces built in full and from the store's view, in flush order. */ + fullBuilt: string[] + workspaceBuilt: string[] + checked: () => number + /** Forgets the builds so far, such as startup's. */ + forgetBuilds: () => void + uninstall: () => void +} { + const failures: string[] = [] + const fullBuilt: string[] = [] + const workspaceBuilt: string[] = [] + let checked = 0 + observeLayoutFrameApply((observation) => { + checked += 1 + fullBuilt.push(...observation.fullBuilt) + workspaceBuilt.push(...observation.workspaceBuilt) + failures.push(...layoutFrameApplyErrors(observation)) + }) + return { + failures, + fullBuilt, + workspaceBuilt, + checked: () => checked, + forgetBuilds: () => { + fullBuilt.length = 0 + workspaceBuilt.length = 0 + }, + uninstall: () => observeLayoutFrameApply(null) + } +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-apply-observer.ts b/src/renderer/src/runtime/workspace-layout-frame-apply-observer.ts new file mode 100644 index 00000000000..2e25c3e7074 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-apply-observer.ts @@ -0,0 +1,34 @@ +// Test hook on the window's frame apply: `installLayoutFrameApplyCheck` (installed by the frame +// tests) checks after every flush that the store shows what the full build shows for the same +// cache, view and facts. Release and dev builds install none. + +import type { AppState } from '@/store/types' +import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' +import type { TerminalTab } from '../../../shared/terminal-tab-types' + +export type LayoutFrameApplyObservation = { + /** The store before and after the flush. */ + before: AppState + after: AppState + cache: WorkspaceLayoutCache + /** Workspaces whose frames the flush showed. */ + changed: readonly string[] + /** Of those, the ones built in full and the ones built from the store's view. */ + fullBuilt: readonly string[] + workspaceBuilt: readonly string[] + /** The window's own fields each new row took (each intent is taken once). */ + rowFieldsTaken: ReadonlyMap> +} + +type LayoutFrameApplyObserver = (observation: LayoutFrameApplyObservation) => void + +let observer: LayoutFrameApplyObserver | null = null + +/** Null removes the observer. */ +export function observeLayoutFrameApply(next: LayoutFrameApplyObserver | null): void { + observer = next +} + +export function layoutFrameApplyObserver(): LayoutFrameApplyObserver | null { + return observer +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts b/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts index 2eddaa599c1..6585fff488e 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts @@ -1,6 +1,5 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' -import type * as WorkspaceSessionModule from '@/lib/workspace-session' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' @@ -14,7 +13,6 @@ import { GIT_KEY, leaf } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture' import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' import { createTestStore } from '../store/slices/store-test-helpers' @@ -23,17 +21,14 @@ import { endLayoutFramePositions } from './layout-frame-position' import { holdTabPaneTree } from './layout-owner-pane-intents' import { holdDrawnTabs } from './layout-owner-drawn-tab-holds' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' +import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture' +import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) -// The rebuild reads the window's session back first: a call means a workspace rebuild ran. -vi.mock('@/lib/workspace-session', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, buildWorkspaceSessionPayload: vi.fn(actual.buildWorkspaceSessionPayload) } -}) const stream = vi.hoisted( (): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null }) @@ -99,15 +94,14 @@ async function boot() { await bootWindowFromPartitions(store, [{ hostId: 'local', session }]) const sync = openWorkspaceLayoutFrameSync(store) const first = publishedOf(session) - stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: first }] }) + const frames = mainLayoutFrameSender() + stream.onFrame!(frames.snapshot({ [GIT_KEY]: first })) sync.start() await settle() - vi.mocked(buildWorkspaceSessionPayload).mockClear() + check.forgetBuilds() const send = async (layout: PublishedWorkspaceLayout, asSnapshot = false): Promise => { stream.onFrame!( - asSnapshot - ? { type: 'snapshot', workspaces: [{ key: GIT_KEY, layout }] } - : { type: 'workspace', key: GIT_KEY, layout } + asSnapshot ? frames.snapshot({ [GIT_KEY]: layout }) : frames.records(GIT_KEY, layout) ) await settle() } @@ -127,7 +121,15 @@ async function rebuiltState(frames: PublishedWorkspaceLayout[]) { const slicesOf = (state: Record) => Object.fromEntries(SLICES.map((slice) => [slice, state[slice]])) -afterEach(() => endLayoutFramePositions()) +let check: ReturnType +beforeEach(() => { + check = installLayoutFrameApplyCheck() +}) +afterEach(() => { + endLayoutFramePositions() + check.uninstall() + expect(check.failures).toEqual([]) +}) describe('a frame that changes only panes’ terminal bindings', () => { // A respawn of a gone process: its pane is unbound, then bound again. @@ -154,9 +156,9 @@ describe('a frame that changes only panes’ terminal bindings', () => { const shown = store.getState() sync.close() - expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled() + expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([]) const rebuilt = await rebuiltState(frames) - expect(buildWorkspaceSessionPayload).toHaveBeenCalled() + expect(check.fullBuilt).not.toEqual([]) expect(slicesOf(shown)).toEqual(slicesOf(rebuilt)) // Presence: the binding the frame names is the store's. const [leafId, ptyId] = steps.at(-1)! @@ -182,11 +184,11 @@ describe('a frame that changes only panes’ terminal bindings', () => { stop() expect(writes).not.toHaveBeenCalled() - expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled() + expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([]) sync.close() }) - it('keeps a tab the window’s own pane command holds as the store has it', async () => { + it('keeps a tab the window’s own pane command holds as the store has it, then shows its binding', async () => { const { store, sync, first, send } = await boot() const before = store.getState().terminalLayoutsByTabId['tab-shell'] const release = holdTabPaneTree('tab-shell') @@ -195,10 +197,15 @@ describe('a frame that changes only panes’ terminal bindings', () => { expect(store.getState().terminalLayoutsByTabId['tab-shell']).toBe(before) release() + await settle() + // Released, the tab shows the binding main wrote meanwhile. + expect(store.getState().terminalLayoutsByTabId['tab-shell']!.ptyIdsByLeafId?.[leaf(1)]).toBe( + 'pty-fresh' + ) sync.close() }) - it('keeps a tab the window drew before main answered as the store has it', async () => { + it('keeps a tab the window drew before main answered as the store has it, then shows its binding', async () => { const { store, sync, first, send } = await boot() const before = store.getState().terminalLayoutsByTabId['tab-shell'] const release = holdDrawnTabs([{ workspace: GIT_KEY, ids: ['tab-shell'] }]) @@ -207,17 +214,23 @@ describe('a frame that changes only panes’ terminal bindings', () => { expect(store.getState().terminalLayoutsByTabId['tab-shell']).toBe(before) release() + await settle() + // Released, the tab shows the binding main wrote meanwhile. + expect(store.getState().terminalLayoutsByTabId['tab-shell']!.ptyIdsByLeafId?.[leaf(1)]).toBe( + 'pty-fresh' + ) sync.close() }) - it('still rebuilds a frame that also changes structure', async () => { + it('builds only its workspace, from the store’s view, for a frame that also renames', async () => { const { store, sync, first, send } = await boot() const renamed = rebound(first, 'tab-shell', leaf(1), 'pty-fresh') renamed.tabs.find((tab) => tab.entityId === 'tab-shell')!.customTitle = 'renamed' await send(renamed) - expect(buildWorkspaceSessionPayload).toHaveBeenCalled() + expect(check.fullBuilt).toEqual([]) + expect(check.workspaceBuilt).toEqual([GIT_KEY]) expect( store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell') ).toMatchObject({ customTitle: 'renamed' }) diff --git a/src/renderer/src/runtime/workspace-layout-frame-bindings.ts b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts index 5d064da4bf5..d2d5f131cb7 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-bindings.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts @@ -1,6 +1,6 @@ -// A frame that changes only its panes' terminal bindings shows without a workspace rebuild: the -// store's pane trees take the frame's bindings, and each tab's focused pane is resolved as the -// rebuild resolves it. Anything only a rebuild shows exactly falls back to one. +// Panes' terminal bindings a frame changed show without a workspace build: the store's pane trees +// take the frame's bindings, and each tab's focused pane is resolved as the build resolves it. +// Anything only a build shows exactly falls back to one. import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' import type { AppState } from '@/store/types' @@ -19,14 +19,16 @@ function sharesATerminal(layout: PublishedWorkspaceLayout): boolean { } /** - * `layouts` with the frame's bindings shown, the same object when it already shows them. Null - * when only a rebuild shows them as the rebuild would: a terminal two panes name (restore releases - * one), a pane tree the store lacks or draws otherwise, or a tab the window's own command holds. + * `layouts` with the frame's bindings of the `rebound` terminal tabs shown, the same object when it + * already shows them. A tab the window's own command holds stays as the store has it, as a build + * keeps it. Null when only a build shows them as it would: a terminal two panes name (restore + * releases one), or a pane tree the store lacks or draws otherwise. */ export function terminalLayoutsWithFrameBindings( layouts: TerminalLayouts, workspace: string, - frame: PublishedWorkspaceLayout + frame: PublishedWorkspaceLayout, + rebound: ReadonlySet ): TerminalLayouts | null { if (sharesATerminal(frame)) { return null @@ -34,7 +36,13 @@ export function terminalLayoutsWithFrameBindings( let next = layouts for (const tab of frame.tabs) { const panes = tab.terminal?.panes - if (!panes) { + if ( + !panes || + !rebound.has(tab.entityId) || + isTabPaneTreeHeld(tab.entityId) || + isDrawnTabHeld(workspace, tab.id) || + isDrawnTabHeld(workspace, tab.entityId) + ) { continue } const current = layouts[tab.entityId] @@ -46,10 +54,7 @@ export function terminalLayoutsWithFrameBindings( if ( !current?.root || panes.some((pane) => !layoutContainsLeafId(current.root, pane.leafId)) || - Object.keys(bound).some((leafId) => !leafIds.has(leafId)) || - isTabPaneTreeHeld(tab.entityId) || - isDrawnTabHeld(workspace, tab.id) || - isDrawnTabHeld(workspace, tab.entityId) + Object.keys(bound).some((leafId) => !leafIds.has(leafId)) ) { return null } diff --git a/src/renderer/src/runtime/workspace-layout-frame-builds.ts b/src/renderer/src/runtime/workspace-layout-frame-builds.ts new file mode 100644 index 00000000000..247227c2854 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-builds.ts @@ -0,0 +1,89 @@ +// The two ways a workspace's store slices are built from its frame: the full build reads this +// window's view and facts back through its save and the Loader; the workspace build reads them +// from the store's records. Both run every restore builder over the named workspaces only, and +// lay the result over the store record by record (workspace-layout-frame-store-patch). + +import type { AppState } from '@/store/types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' +import { buildLayoutFrameSlices } from '@/store/workspace-layout-frame-slices' +import { buildLayoutFrameStorePatch } from '@/store/workspace-layout-frame-store-patch' +import type { HeldDrawnTabs } from '@/store/workspace-layout-frame-held-tabs' +import { layoutViewAndFactsOfStore } from '@/store/workspace-layout-frame-store-view' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { splitWorkspaceSessionByHost } from '@/lib/workspace-session-host-split' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import type { + DesktopLayoutView, + LayoutContentFacts +} from '../../../shared/workspace-layout/workspace-layout-beside' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' + +/** What the window's own commands hold as drawn; see buildLayoutFrameStorePatch. */ +export type LayoutFrameBuildHolds = { + keepsTerminalLayout: (tabId: string) => boolean + newRowFields: (tabId: string) => Partial | null + held: HeldDrawnTabs +} + +export type LayoutFrameBuildPatch = Partial + +/** A bucket, not a host: the view read-back routes the workspaces a build skips here. */ +const OTHER_WORKSPACES: ExecutionHostId = 'runtime:frame-sync-other-workspaces' + +function patchFromSlices( + state: AppState, + cache: WorkspaceLayoutCache, + keys: readonly string[], + { view, facts }: { view: DesktopLayoutView; facts: LayoutContentFacts }, + holds: LayoutFrameBuildHolds +): LayoutFrameBuildPatch { + const heldFiles = new Set(state.openFiles.map((file) => `${file.worktreeId}\0${file.id}`)) + const built = buildLayoutFrameSlices({ + layouts: Object.fromEntries(keys.map((key) => [key, cache[key]!])), + facts, + view, + holdsEditorFile: (worktreeId, fileId) => heldFiles.has(`${worktreeId}\0${fileId}`) + }) + return buildLayoutFrameStorePatch( + state, + built, + keys, + holds.keepsTerminalLayout, + holds.newRowFields, + holds.held + ) +} + +/** + * Startup, a reopened stream and a workspace new to the stream: the Loader also repairs the + * legacy shapes a session restored from disk can hold, which the store's records then still have. + */ +export function fullLayoutFrameBuildPatch( + state: AppState, + cache: WorkspaceLayoutCache, + keys: readonly string[], + holds: LayoutFrameBuildHolds +): LayoutFrameBuildPatch { + const built = new Set(keys) + const own = splitWorkspaceSessionByHost(buildWorkspaceSessionPayload(state), (key) => + built.has(key) ? LOCAL_EXECUTION_HOST_ID : OTHER_WORKSPACES + )[LOCAL_EXECUTION_HOST_ID]! + const { facts, desktopView } = loadWorkspaceLayoutWithoutReport( + LOCAL_EXECUTION_HOST_ID, + own, + nameBasedLoadContext() + ) + return patchFromSlices(state, cache, keys, { view: desktopView, facts }, holds) +} + +/** A workspace the store already shows from frames, whose records a frame changed. */ +export function workspaceLayoutFrameBuildPatch( + state: AppState, + cache: WorkspaceLayoutCache, + keys: readonly string[], + holds: LayoutFrameBuildHolds +): LayoutFrameBuildPatch { + return patchFromSlices(state, cache, keys, layoutViewAndFactsOfStore(state, keys), holds) +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-changes.ts b/src/renderer/src/runtime/workspace-layout-frame-changes.ts index 9c6b124ad1d..c3668fbfda0 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-changes.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-changes.ts @@ -1,69 +1,22 @@ -// What changed between two frames of one workspace: its structure, only its panes' terminal -// bindings, or only their live titles. Each JSON is kept per frame: title frames arrive at spinner -// rate, and a respawned terminal's binding frames one per restored pane. +// Live title changes of the terminal tabs a frame retitled: title frames arrive at spinner rate, +// so each shows as a title update of its tab alone. import { shownLiveTitle } from '@/store/workspace-layout-frame-slices' import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch' -import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' import type { - PublishedPane, + PublishedTab, PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' -const structureJsonByLayout = new WeakMap() -const unboundJsonByLayout = new WeakMap() - -function jsonWithoutPaneFields( - layout: PublishedWorkspaceLayout, - strip: (pane: PublishedPane) => object -): string { - return stableJson({ - ...layout, - tabs: layout.tabs.map((tab) => - tab.terminal - ? { ...tab, terminal: { ...tab.terminal, panes: tab.terminal.panes.map(strip) } } - : tab - ) - }) -} - -function cached( - cache: WeakMap, - layout: PublishedWorkspaceLayout, - strip: (pane: PublishedPane) => object -): string { - let json = cache.get(layout) - if (json === undefined) { - json = jsonWithoutPaneFields(layout, strip) - cache.set(layout, json) - } - return json -} - -/** A frame's JSON without live titles. */ -export function structureJson(layout: PublishedWorkspaceLayout): string { - return cached(structureJsonByLayout, layout, ({ liveTitle: _liveTitle, ...pane }) => pane) -} - -/** A frame's JSON without live titles or terminal bindings: a pane's binding is its status. */ -export function unboundStructureJson(layout: PublishedWorkspaceLayout): string { - return cached( - unboundJsonByLayout, - layout, - ({ liveTitle: _liveTitle, ptyId: _ptyId, ptyGeneration: _ptyGeneration, ...pane }) => pane - ) -} - -/** Title changes between two frames of one workspace, for the pane each tab shows. */ +/** Each retitled tab's change for the pane it shows, against the tab as the store showed it. */ export function liveTitleUpdates( - shown: PublishedWorkspaceLayout, + titled: ReadonlyMap, next: PublishedWorkspaceLayout, focusedLeafId: (tabId: string) => string | null | undefined ): TerminalTabTitleUpdate[] { - const shownById = new Map(shown.tabs.map((tab) => [tab.id, tab])) const updates: TerminalTabTitleUpdate[] = [] for (const tab of next.tabs) { - const shownTab = shownById.get(tab.id) + const shownTab = titled.get(tab.entityId) if (!tab.terminal || !shownTab) { continue } diff --git a/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts b/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts index 709beb539c4..e6e4c2d7c31 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts @@ -23,6 +23,8 @@ import { recordOpenedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layou import { withLayoutOwnerTabProps } from '@/components/tab-bar/tab-bar-layout-owner-props' import { endLayoutFramePositions } from './layout-frame-position' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' +import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture' +import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture' vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -91,11 +93,12 @@ function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout { const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) let seq = 0 +let frames = mainLayoutFrameSender() /** A frame main sent before it had the window's change: another tab got renamed. */ function sendStaleFrame(layout: PublishedWorkspaceLayout): void { const stale = structuredClone(layout) stale.tabs.find((tab) => tab.id === 'tab-unbound')!.customTitle = `renamed ${++seq}` - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: stale, seq }) + stream.onFrame!(frames.records(GIT_KEY, stale, seq)) stream.onPosition!(seq) } @@ -105,7 +108,8 @@ async function boot() { await bootWindowFromPartitions(store, [{ hostId: 'local', session }]) const sync = openWorkspaceLayoutFrameSync(store) const before = publishedOf(session) - stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: before }], seq: ++seq }) + frames = mainLayoutFrameSender() + stream.onFrame!(frames.snapshot({ [GIT_KEY]: before }, ++seq)) stream.onPosition!(seq) sync.start() await settle() @@ -119,7 +123,7 @@ async function boot() { const frame = publishedOf(buildWorkspaceSessionPayload(store.getState())) mainChange(frame) seq += 1 - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: frame, seq }) + stream.onFrame!(frames.records(GIT_KEY, frame, seq)) stream.onPosition!(seq) rpc.calls[index]!.answer({ ...reply, frameSeq: seq }) await settle() @@ -168,11 +172,17 @@ function drawTab( return drawn } +let check: ReturnType beforeEach(() => { rpc.calls = [] vi.clearAllMocks() + check = installLayoutFrameApplyCheck() +}) +afterEach(() => { + endLayoutFramePositions() + check.uninstall() + expect(check.failures).toEqual([]) }) -afterEach(() => endLayoutFramePositions()) describe('a tab the window drew, closed or moved, with a frame from before main had it', () => { it('keeps a just-created terminal tab, then shows main’s copy', async () => { diff --git a/src/renderer/src/runtime/workspace-layout-frame-operations.test.ts b/src/renderer/src/runtime/workspace-layout-frame-operations.test.ts new file mode 100644 index 00000000000..f588d5db2b1 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-operations.test.ts @@ -0,0 +1,248 @@ +// Main's operations as the window draws them from their records frames (design E2a): each shows +// from the store's own view, without reading the window's session back, and leaves the store as +// the full build does (the apply check, installed below). + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' +import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' +import { testContext } from '../../../shared/workspace-layout/workspace-layout-command.test-fixture' +import type { WorkspaceLayoutModel } from '../../../shared/workspace-layout/workspace-layout-model' +import { localDesktopSession } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture' +import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' +import { endLayoutFramePositions } from './layout-frame-position' +import { + loadMainLayoutModel, + mainLayoutFrameSender, + publishedLayoutsOf +} from './main-loaded-layout-frames.test-fixture' +import { + installLayoutFrameApplyCheck, + layoutFrameApplyErrors +} from './workspace-layout-frame-apply-check.test-fixture' +import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const stream = vi.hoisted( + (): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null }) +) +vi.mock('./runtime-layout-client', () => ({ + subscribeRuntimeLayout: ( + _target: unknown, + _params: unknown, + onFrame: (frame: WorkspaceLayoutChangeFrame) => void + ) => { + stream.onFrame = onFrame + return { close: () => {} } + } +})) + +createStoreSessionMockApi() + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) +const SHELL_LEAF = '11111111-1111-4111-8111-111111111111' + +let check: ReturnType +beforeEach(() => { + check = installLayoutFrameApplyCheck() +}) +afterEach(() => { + endLayoutFramePositions() + check.uninstall() + expect(check.failures).toEqual([]) +}) + +/** A window shown from main's snapshot; `apply` runs a command on main's model and sends its frame. */ +async function boot() { + const session = localDesktopSession() + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId: 'local', session }]) + const sync = openWorkspaceLayoutFrameSync(store) + let model: WorkspaceLayoutModel = loadMainLayoutModel('local', session) + const context = testContext() + const frames = mainLayoutFrameSender() + stream.onFrame!(frames.snapshot(publishedLayoutsOf(model))) + sync.start() + await settle() + check.forgetBuilds() + const apply = async (...commands: LayoutCommand[]): Promise => { + for (const command of commands) { + const applied = applyLayoutCommand(model, command, context) + if (!applied.ok) { + throw new Error(`main refused ${command.type}: ${applied.code}`) + } + model = applied.model + stream.onFrame!(frames.records(GIT_KEY, publishedLayoutsOf(model)[GIT_KEY]!)) + } + await settle() + } + return { store, apply, close: () => sync.close() } +} + +const on = { workspace: GIT_KEY } + +describe('main’s operations drawn record by record', () => { + it.each<[string, LayoutCommand[]]>([ + ['a rename', [{ ...on, type: 'renameTab', tabId: 'tab-shell', kind: 'custom', title: 'api' }]], + ['a color and a pin', [{ ...on, type: 'setTabProps', tabId: 'tab-unbound', color: '#22c55e' }]], + [ + 'a split', + [{ ...on, type: 'splitPane', tabId: 'tab-shell', leafId: SHELL_LEAF, direction: 'vertical' }] + ], + ['a pane close', [{ ...on, type: 'closePane', tabId: 'tab-shell', leafId: SHELL_LEAF }]], + ['a tab close', [{ ...on, type: 'closeTabs', tabIds: ['tab-unbound'] }]], + ['a move', [{ ...on, type: 'moveTab', tabId: 'tab-unbound', toGroupId: 'group-a', index: 0 }]], + [ + 'a group change', + [ + { + ...on, + type: 'splitGroup', + tabId: 'tab-unbound', + besideGroupId: 'group-b', + direction: 'down' + } + ] + ], + ['a new terminal', [{ ...on, type: 'createTerminalTab', groupId: 'group-b', title: 'new' }]], + [ + 'an editor open', + [ + { + ...on, + type: 'openEditorTab', + fileId: '/Users/dev/orca/src/main.ts', + contentType: 'editor', + file: { + filePath: '/Users/dev/orca/src/main.ts', + relativePath: 'src/main.ts', + language: 'typescript' + }, + groupId: 'group-a' + } + ] + ] + ])('shows %s without the full build', async (_, commands) => { + const { store, apply, close } = await boot() + const before = store.getState() + + await apply(...commands) + + expect(check.fullBuilt).toEqual([]) + expect(check.workspaceBuilt).toEqual([GIT_KEY]) + // Presence: the frame changed what the store shows. + expect(store.getState()).not.toBe(before) + close() + }) +}) + +type Store = Awaited>['store'] + +const groupsOf = (store: Store) => store.getState().groupsByWorktree[GIT_KEY]! + +describe('a frame over view state the save trims', () => { + it.each<[string, (store: Store) => void]>([ + [ + 'a focused diff tab, which the save does not keep', + (store) => { + const file = store.getState().openFiles.find((open) => open.worktreeId === GIT_KEY)! + const diff = { ...file, id: `${file.id}::diff`, mode: 'diff' as const } + store.setState({ + openFiles: [...store.getState().openFiles, diff], + activeFileIdByWorktree: { + ...store.getState().activeFileIdByWorktree, + [GIT_KEY]: diff.id + }, + activeTabTypeByWorktree: { + ...store.getState().activeTabTypeByWorktree, + [GIT_KEY]: 'editor' + } + }) + } + ], + [ + 'a group whose shown tab is gone', + (store) => + store.setState({ + groupsByWorktree: { + [GIT_KEY]: groupsOf(store).map((group, index) => + index === 0 ? { ...group, activeTabId: 'gone' } : group + ) + } + }) + ], + [ + 'an empty group, active', + (store) => + store.setState({ + groupsByWorktree: { + [GIT_KEY]: [ + ...groupsOf(store), + { id: 'group-empty', worktreeId: GIT_KEY, activeTabId: null, tabOrder: [] } + ] + }, + activeGroupIdByWorktree: { [GIT_KEY]: 'group-empty' } + }) + ], + [ + 'a group id twice, where the Loader keeps the first', + (store) => + store.setState({ + groupsByWorktree: { + [GIT_KEY]: [...groupsOf(store), { ...groupsOf(store)[0]!, activeTabId: 'tab-shell' }] + } + }) + ] + ])('shows a rename over %s as the full build does', async (_, drawState) => { + const { store, apply, close } = await boot() + drawState(store) + + await apply({ ...on, type: 'renameTab', tabId: 'tab-shell', kind: 'custom', title: 'api' }) + + expect(check.fullBuilt).toEqual([]) + expect(check.workspaceBuilt).toEqual([GIT_KEY]) + close() + }) +}) + +describe('the apply check on a flush that built one workspace in full', () => { + it('still checks the workspace built from the store view', async () => { + const { store, close } = await boot() + const shown = store.getState() + const cache = publishedLayoutsOf(loadMainLayoutModel('local', localDesktopSession())) + const observe = (after: typeof shown) => + layoutFrameApplyErrors({ + before: shown, + after, + cache, + changed: [GIT_KEY, 'repo-other::/elsewhere'], + fullBuilt: ['repo-other::/elsewhere'], + workspaceBuilt: [GIT_KEY], + rowFieldsTaken: new Map() + }) + // Precondition: the store as booted is what the full build shows. + expect(observe(shown)).toEqual([]) + + const wrong = { + ...shown, + unifiedTabsByWorktree: { + ...shown.unifiedTabsByWorktree, + [GIT_KEY]: shown.unifiedTabsByWorktree[GIT_KEY]!.map((tab) => ({ + ...tab, + customTitle: 'drawn wrong' + })) + } + } + expect(observe(wrong)).toEqual([expect.stringContaining('unifiedTabsByWorktree')]) + close() + }) +}) diff --git a/src/renderer/src/runtime/workspace-layout-frame-pane-trees.ts b/src/renderer/src/runtime/workspace-layout-frame-pane-trees.ts new file mode 100644 index 00000000000..7f1190c5fa7 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-pane-trees.ts @@ -0,0 +1,39 @@ +// Main's pane trees shown at once, against the store rather than the frame: a tab the window's own +// pane command held may lag the trees drawn since. Only a tab it still holds waits. + +import type { AppState } from '@/store/types' +import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' +import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' +import { isTabPaneTreeHeld } from './layout-owner-pane-intents' + +type TerminalLayouts = AppState['terminalLayoutsByTabId'] + +/** `layouts` with the frames' trees of `keys`, and the workspaces a held tab still keeps waiting. */ +export function terminalLayoutsWithFramePaneTrees( + layouts: TerminalLayouts, + cache: WorkspaceLayoutCache, + keys: Iterable, + drawn: (key: string) => boolean +): { layouts: TerminalLayouts; waiting: Set } { + let next = layouts + const waiting = new Set() + for (const key of keys) { + const layout = cache[key] + if (!layout || !drawn(key)) { + continue + } + for (const tab of layout.tabs) { + const root = tab.terminal?.root + const current = root ? next[tab.entityId] : undefined + if (!root || !current) { + continue + } + if (isTabPaneTreeHeld(tab.entityId)) { + waiting.add(key) + } else if (stableJson(current.root) !== stableJson(root)) { + next = { ...next, [tab.entityId]: { ...current, root } } + } + } + } + return { layouts: next, waiting } +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-pending.ts b/src/renderer/src/runtime/workspace-layout-frame-pending.ts new file mode 100644 index 00000000000..a08ff90701d --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-pending.ts @@ -0,0 +1,132 @@ +// What a workspace's records frames changed since the store last showed it, kept per record so a +// flush shows only those records: panes' terminal bindings and live titles apply alone; any other +// record kind takes a workspace build. + +import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' +import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' +import type { WorkspaceLayoutRecordsFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import type { + PublishedPane, + PublishedTab, + PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +import type { + PublishedPaneTree, + WorkspaceLayoutRecordChange +} from '../../../shared/workspace-layout/workspace-layout-records' + +export type PendingWorkspaceChanges = { + /** Tab rows, the tab set or order, groups, a pane tree's shape, editor files or browser tabs. */ + rebuild: boolean + /** Terminal entity id → the tab as the store last showed it, for tabs whose live titles changed. */ + titled: Map + /** Terminal entity ids whose panes' bindings changed. */ + rebound: Set +} + +/** The workspace's pending changes, started empty. */ +export function pendingWorkspaceChanges( + pending: Map, + key: string +): PendingWorkspaceChanges { + let changes = pending.get(key) + if (!changes) { + changes = { rebuild: false, titled: new Map(), rebound: new Set() } + pending.set(key, changes) + } + return changes +} + +/** A pane tree without its panes' live titles or bindings. */ +function unboundPaneTreeJson( + tree: Pick +): string { + return stableJson({ + root: tree.root, + chatLeafId: tree.chatLeafId, + panes: tree.panes.map( + ({ + liveTitle: _liveTitle, + ptyId: _ptyId, + ptyGeneration: _ptyGeneration, + ...pane + }: PublishedPane) => pane + ) + }) +} + +function tabOwningLeaf( + layouts: readonly PublishedWorkspaceLayout[], + leafId: string +): PublishedTab | undefined { + for (const layout of layouts) { + const tab = layout.tabs.find((candidate) => + candidate.terminal?.panes.some((pane) => pane.leafId === leafId) + ) + if (tab) { + return tab + } + } + return undefined +} + +/** Adds one records change to what `pending` holds; `before` is the cache's copy it applied to. */ +function notePendingRecordChange( + pending: PendingWorkspaceChanges, + before: PublishedWorkspaceLayout, + after: PublishedWorkspaceLayout, + change: Extract +): void { + if (change.workspace || change.tabs || change.editorFiles || change.browserTabs) { + pending.rebuild = true + } + if (change.paneTrees?.removed?.length) { + pending.rebuild = true + } + for (const tree of change.paneTrees?.set ?? []) { + const shown = before.tabs.find((tab) => tab.id === tree.tabId) + if (!shown?.terminal || unboundPaneTreeJson(shown.terminal) !== unboundPaneTreeJson(tree)) { + pending.rebuild = true + } else if (!pending.titled.has(shown.entityId)) { + pending.titled.set(shown.entityId, shown) + } + } + for (const leafId of [ + ...(change.bindings?.set ?? []).map((binding) => binding.leafId), + ...(change.bindings?.removed ?? []) + ]) { + const tab = tabOwningLeaf([after, before], leafId) + if (tab) { + pending.rebound.add(tab.entityId) + } else { + pending.rebuild = true + } + } +} + +/** + * Adds a records frame's changes, each against the cache's copy `before` it applied to. A + * workspace new to the stream, sent whole or gone leaves `shown`: only a full build shows it. + */ +export function notePendingRecordsFrame( + pending: Map, + shown: Set, + before: WorkspaceLayoutCache, + after: WorkspaceLayoutCache, + frame: WorkspaceLayoutRecordsFrame +): void { + for (const change of frame.workspaces) { + const shownLayout = before[change.key] + const next = after[change.key] + if (change.change !== 'records' || !shownLayout || !next) { + shown.delete(change.key) + } else { + notePendingRecordChange( + pendingWorkspaceChanges(pending, change.key), + shownLayout, + next, + change + ) + } + } +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-records.test.ts b/src/renderer/src/runtime/workspace-layout-frame-records.test.ts index 906ca5d4217..4d28e8a12bc 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-records.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-records.test.ts @@ -1,6 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type * as WorkspaceSessionModule from '@/lib/workspace-session' +import type * as WorkspaceLayoutLoadModule from '../../../shared/workspace-layout/workspace-layout-load' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' @@ -8,33 +10,39 @@ import { publishWorkspaceLayout, type PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' -import { - diffPublishedLayouts, - indexPublishedLayout -} from '../../../shared/workspace-layout/workspace-layout-records' import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' import { addWorkspace, emptySession, GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture' import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' import { createTestStore } from '../store/slices/store-test-helpers' +import type { LayoutFrameSlices } from '../store/workspace-layout-frame-slices' import { endLayoutFramePositions } from './layout-frame-position' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' +import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture' +import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) -// The rebuild reads the window's session back first: a call means a workspace rebuild ran. + +// The full build reads the window's session back and runs the Loader: a call means it ran. vi.mock('@/lib/workspace-session', async (importOriginal) => { const actual = await importOriginal() return { ...actual, buildWorkspaceSessionPayload: vi.fn(actual.buildWorkspaceSessionPayload) } }) +vi.mock('../../../shared/workspace-layout/workspace-layout-load', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + loadWorkspaceLayoutWithoutReport: vi.fn(actual.loadWorkspaceLayoutWithoutReport) + } +}) const stream = vi.hoisted( (): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null }) @@ -53,6 +61,20 @@ vi.mock('./runtime-layout-client', () => ({ createStoreSessionMockApi() const TABS = 100 +const FRAME_SLICES: (keyof LayoutFrameSlices)[] = [ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'activeTabIdByWorktree', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'activeGroupIdByWorktree', + 'layoutByWorktree', + 'openFiles', + 'activeFileIdByWorktree', + 'browserTabsByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree' +] const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) const leafOf = (index: number) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` @@ -75,40 +97,36 @@ function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout { return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local') } -/** Main's one frame for an operation that turned `before` into `after`. */ -function recordsFrame( - before: PublishedWorkspaceLayout, - after: PublishedWorkspaceLayout -): WorkspaceLayoutChangeFrame { - const change = diffPublishedLayouts( - GIT_KEY, - indexPublishedLayout(before), - indexPublishedLayout(after), - after - ) - return { type: 'records', epoch: 'epoch-1', seq: 1, workspaces: change ? [change] : [] } -} - async function boot() { const store = createTestStore() const session = hundredTabs() await bootWindowFromPartitions(store, [{ hostId: 'local', session }]) const sync = openWorkspaceLayoutFrameSync(store) const first = publishedOf(session) - stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: first }], seq: 0 }) + const frames = mainLayoutFrameSender() + stream.onFrame!(frames.snapshot({ [GIT_KEY]: first }, 0)) sync.start() await settle() - vi.mocked(buildWorkspaceSessionPayload).mockClear() + check.forgetBuilds() const writes = vi.fn() const stopWrites = store.subscribe(writes) - const send = async (frame: WorkspaceLayoutChangeFrame): Promise => { - stream.onFrame!(frame) + /** Main's one frame for an operation that left the workspace as `layout`. */ + const send = async (layout: PublishedWorkspaceLayout): Promise => { + stream.onFrame!(frames.records(GIT_KEY, layout)) await settle() } return { store, first, writes, send, close: () => (stopWrites(), sync.close()) } } -afterEach(() => endLayoutFramePositions()) +let check: ReturnType +beforeEach(() => { + check = installLayoutFrameApplyCheck() +}) +afterEach(() => { + endLayoutFramePositions() + check.uninstall() + expect(check.failures).toEqual([]) +}) describe('a records frame in the window', () => { it('shows 100 generated titles from one frame in one store update', async () => { @@ -118,7 +136,7 @@ describe('a records frame in the window', () => { tab.generatedTitle = `Title ${index}` }) - await send(recordsFrame(first, titled)) + await send(titled) expect(writes).toHaveBeenCalledTimes(1) expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.generatedTitle)).toEqual( @@ -127,30 +145,97 @@ describe('a records frame in the window', () => { close() }) + it('shows a frame of only tab rows without reading the window’s session back', async () => { + const { store, first, writes, send, close } = await boot() + // The apply check runs the full build itself; this counts only the window's own calls. + check.uninstall() + vi.mocked(buildWorkspaceSessionPayload).mockClear() + vi.mocked(loadWorkspaceLayoutWithoutReport).mockClear() + const titled = structuredClone(first) + titled.tabs.forEach((tab, index) => { + tab.generatedTitle = `Title ${index}` + }) + + await send(titled) + + expect(writes).toHaveBeenCalledTimes(1) + expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled() + expect(loadWorkspaceLayoutWithoutReport).not.toHaveBeenCalled() + // Presence: the rows changed. + expect(store.getState().tabsByWorktree[GIT_KEY]![99]!.generatedTitle).toBe('Title 99') + close() + }) + it('shows a binding record without a rebuild', async () => { const { store, first, send, close } = await boot() const rebound = structuredClone(first) rebound.tabs[7]!.terminal!.panes[0]!.ptyId = 'pty-fresh' rebound.tabs[7]!.terminal!.panes[0]!.ptyGeneration = 2 - await send(recordsFrame(first, rebound)) + await send(rebound) - expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled() + expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([]) expect(store.getState().terminalLayoutsByTabId['tab-7']!.ptyIdsByLeafId).toEqual({ [leafOf(7)]: 'pty-fresh' }) close() }) + it('rebinds a pane in its one store record, keeping its row and tree so the xterm stays mounted', async () => { + const { store, first, send, close } = await boot() + const before = store.getState() + const rebound = structuredClone(first) + rebound.tabs[7]!.terminal!.panes[0]!.ptyId = 'pty-fresh' + + await send(rebound) + + const after = store.getState() + expect(FRAME_SLICES.filter((slice) => after[slice] !== before[slice])).toEqual([ + 'terminalLayoutsByTabId' + ]) + const changedLayouts = Object.keys(after.terminalLayoutsByTabId).filter( + (tabId) => after.terminalLayoutsByTabId[tabId] !== before.terminalLayoutsByTabId[tabId] + ) + expect(changedLayouts).toEqual(['tab-7']) + expect(after.terminalLayoutsByTabId['tab-7']!.root).toBe( + before.terminalLayoutsByTabId['tab-7']!.root + ) + // The pane renderer is keyed by its row's id and generation: the same row, the same mount. + expect(after.tabsByWorktree[GIT_KEY]![7]).toBe(before.tabsByWorktree[GIT_KEY]![7]) + expect(after.terminalLayoutsByTabId['tab-7']!.ptyIdsByLeafId).toEqual({ + [leafOf(7)]: 'pty-fresh' + }) + close() + }) + + it('shows 100 single-binding frames, one per task, without building a workspace', async () => { + const { store, first, send, close } = await boot() + let layout = first + for (let index = 0; index < TABS; index += 1) { + layout = structuredClone(layout) + layout.tabs[index]!.terminal!.panes[0]!.ptyId = `pty-respawned-${index}` + await send(layout) + } + + expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([]) + const bindings = store.getState().terminalLayoutsByTabId + expect( + Array.from({ length: TABS }, (_, index) => bindings[`tab-${index}`]!.ptyIdsByLeafId) + ).toEqual( + Array.from({ length: TABS }, (_, index) => ({ [leafOf(index)]: `pty-respawned-${index}` })) + ) + close() + }) + it('writes nothing for a respawn under the same id: only its generation rose', async () => { const { first, writes, send, close } = await boot() const respawned = structuredClone(first) respawned.tabs[3]!.terminal!.panes[0]!.ptyGeneration = 2 - await send(recordsFrame(first, respawned)) + await send(respawned) expect(writes).not.toHaveBeenCalled() - expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled() + expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([]) close() }) }) diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts index 1f4780e8291..cf5fdaf884c 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { ExecutionHostId } from '../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' @@ -24,7 +24,12 @@ import { } from './layout-frame-position' import { holdTabPaneTree, requestTabPaneTreeRedraw } from './layout-owner-pane-intents' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' -import { layoutSnapshotFrame, mainLoadedLayouts } from './main-loaded-layout-frames.test-fixture' +import { + layoutSnapshotFrame, + mainLayoutFrameSender, + mainLoadedLayouts +} from './main-loaded-layout-frames.test-fixture' +import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { @@ -87,8 +92,9 @@ async function bootWithFrames(hostId: ExecutionHostId, session: WorkspaceSession await bootWindowFromPartitions(store, [{ hostId, session }]) const sync = openWorkspaceLayoutFrameSync(store) const layouts = mainLoadedLayouts(hostId, session) - stream.onFrame!(layoutSnapshotFrame(layouts)) - return { store, sync, layouts } + const frames = mainLayoutFrameSender() + stream.onFrame!(frames.snapshot(layouts)) + return { store, sync, layouts, frames } } /** Fixtures whose frames differ from the restore, as the frame parity test lists. */ @@ -107,7 +113,15 @@ const FRAME_DIFFERENCE_FIXTURES = new Set([ 'session from before the tab bar, a row with no terminal' ]) -afterEach(() => endLayoutFramePositions()) +let check: ReturnType +beforeEach(() => { + check = installLayoutFrameApplyCheck() +}) +afterEach(() => { + endLayoutFramePositions() + check.uninstall() + expect(check.failures).toEqual([]) +}) describe('the window drawn from layout frames', () => { it.each( @@ -128,7 +142,7 @@ describe('the window drawn from layout frames', () => { it('shows a structure change main publishes, keeping what only the window knows', async () => { const session = localDesktopSession() - const { store, sync, layouts } = await bootWithFrames('local', session) + const { store, sync, layouts, frames } = await bootWithFrames('local', session) sync.start() store.setState({ tabsByWorktree: { @@ -147,7 +161,7 @@ describe('the window drawn from layout frames', () => { }) const renamed = structuredClone(layouts[GIT_KEY]!) renamed.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'renamed' - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: renamed }) + stream.onFrame!(frames.records(GIT_KEY, renamed)) await flushMicrotasks() const shell = store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')! expect(shell).toMatchObject({ @@ -161,12 +175,12 @@ describe('the window drawn from layout frames', () => { }) it('keeps the window’s pane remount generation through a structure frame: it is the window’s', async () => { - const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) + const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession()) sync.start() store.getState().remountTerminalTabForRecovery('tab-shell') const renamed = structuredClone(layouts[GIT_KEY]!) renamed.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'renamed' - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: renamed }) + stream.onFrame!(frames.records(GIT_KEY, renamed)) await flushMicrotasks() expect( store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell') @@ -185,9 +199,9 @@ describe('the window drawn from layout frames', () => { }) it('runs a reply waiting on a frame after the store shows it, and every waiter when the stream ends', async () => { - const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) + const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession()) sync.start() - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: layouts[GIT_KEY]!, seq: 1 }) + stream.onFrame!(frames.records(GIT_KEY, layouts[GIT_KEY]!, 1)) stream.onPosition!(1) await flushMicrotasks() const shellTitle = () => @@ -198,7 +212,7 @@ describe('the window drawn from layout frames', () => { const renamed = structuredClone(layouts[GIT_KEY]!) renamed.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'renamed' - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: renamed, seq: 2 }) + stream.onFrame!(frames.records(GIT_KEY, renamed, 2)) stream.onPosition!(2) await flushMicrotasks() expect(seen).toEqual(['renamed']) @@ -217,7 +231,7 @@ describe('the window drawn from layout frames', () => { }) it('shows main’s pane tree at once, except a tab the window’s own pane command holds', async () => { - const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) + const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession()) sync.start() const shellRoot = () => store.getState().terminalLayoutsByTabId['tab-shell']!.root const withRatio = (ratio: number) => { @@ -230,12 +244,12 @@ describe('the window drawn from layout frames', () => { } expect(shellRoot()?.type).toBe('split') - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: withRatio(0.2) }) + stream.onFrame!(frames.records(GIT_KEY, withRatio(0.2))) await flushMicrotasks() expect(shellRoot()).toMatchObject({ ratio: 0.2 }) const release = holdTabPaneTree('tab-shell') - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: withRatio(0.7) }) + stream.onFrame!(frames.records(GIT_KEY, withRatio(0.7))) await flushMicrotasks() expect(shellRoot()).toMatchObject({ ratio: 0.2 }) @@ -246,7 +260,7 @@ describe('the window drawn from layout frames', () => { }) it('shows main’s pane tree going back to one the window showed before', async () => { - const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) + const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession()) sync.start() const shellRoot = () => store.getState().terminalLayoutsByTabId['tab-shell']!.root const original = shellRoot() @@ -255,23 +269,23 @@ describe('the window drawn from layout frames', () => { if (root.type === 'split') { root.ratio = 0.15 } - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: changed }) + stream.onFrame!(frames.records(GIT_KEY, changed)) await flushMicrotasks() expect(shellRoot()).toMatchObject({ ratio: 0.15 }) // Same structure as the frame shown before: its pane tree still shows. - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: structuredClone(layouts[GIT_KEY]!) }) + stream.onFrame!(frames.records(GIT_KEY, structuredClone(layouts[GIT_KEY]!))) await flushMicrotasks() expect(shellRoot()).toEqual(original) sync.close() }) it('keeps the window’s own pane trees once the stream has ended', async () => { - const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) + const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession()) sync.start() await flushMicrotasks() // A frame still queued when the stream ends. - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: structuredClone(layouts[GIT_KEY]!) }) + stream.onFrame!(frames.records(GIT_KEY, structuredClone(layouts[GIT_KEY]!))) stream.onStatus!({ state: 'lost' }) const drawn = { type: 'leaf' as const, leafId: 'drawn-by-the-window' } store.setState({ @@ -319,7 +333,7 @@ describe('the window drawn from layout frames', () => { }) it('shows a live title change of the focused pane without rebuilding structure', async () => { - const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) + const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession()) sync.start() const titled = structuredClone(layouts[GIT_KEY]!) const shell = titled.tabs.find((tab) => tab.id === 'tab-shell')! @@ -329,7 +343,7 @@ describe('the window drawn from layout frames', () => { liveTitle: pane.leafId === focused ? 'vim notes.md' : 'other pane' })) const before = store.getState() - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: titled }) + stream.onFrame!(frames.records(GIT_KEY, titled)) await flushMicrotasks() const after = store.getState() expect(after.tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.title).toBe( @@ -344,11 +358,11 @@ describe('the window drawn from layout frames', () => { const profile = WORKSPACE_LAYOUT_PROFILES.find( (entry) => entry.name === 'remote server partition' )! - const { store, sync, layouts } = await bootWithFrames(profile.hostId, profile.build()) + const { store, sync, layouts, frames } = await bootWithFrames(profile.hostId, profile.build()) sync.start() const before = store.getState() const [key, layout] = Object.entries(layouts)[0]! - stream.onFrame!({ type: 'workspace', key, layout: { ...layout, tabs: [], groups: [] } }) + stream.onFrame!(frames.records(key, { ...layout, tabs: [], groups: [] })) await flushMicrotasks() expect(SLICES.filter((slice) => store.getState()[slice] !== before[slice])).toEqual([]) sync.close() diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index 87f038bd86d..a4699b21964 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -5,35 +5,40 @@ // // A tab the window drew, closed or moved before main answered stays as drawn until the answer is // in (layout-owner-drawn-tab-holds); everything else shows as each frame arrives. +// +// A records frame shows only what it changed (design E2a): panes' bindings and live titles apply +// alone, and any other record builds its workspace from the store's view. Only a workspace the +// store does not show from frames yet (startup, a reopened stream, a first visit, one sent whole) +// takes the full build, whose view the window reads back through its save and the Loader. import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspace-layout-cache' import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' -import { buildLayoutFrameSlices } from '@/store/workspace-layout-frame-slices' -import { - buildLayoutFrameStorePatch, - terminalLayoutsPatch -} from '@/store/workspace-layout-frame-store-patch' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { terminalLayoutsPatch } from '@/store/workspace-layout-frame-store-patch' import { framesDrawWorkspace, layoutHoldsBoundPane, onWorkspaceStartsDrawing } from './workspace-layout-frame-drawn-workspaces' -import { splitWorkspaceSessionByHost } from '@/lib/workspace-session-host-split' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch' -import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' -import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' -import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' -import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' -import { - liveTitleUpdates, - structureJson, - unboundStructureJson -} from './workspace-layout-frame-changes' +import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import { liveTitleUpdates } from './workspace-layout-frame-changes' import { terminalLayoutsWithFrameBindings } from './workspace-layout-frame-bindings' +import { terminalLayoutsWithFramePaneTrees } from './workspace-layout-frame-pane-trees' +import { + fullLayoutFrameBuildPatch, + workspaceLayoutFrameBuildPatch, + type LayoutFrameBuildHolds, + type LayoutFrameBuildPatch +} from './workspace-layout-frame-builds' +import { + notePendingRecordsFrame, + pendingWorkspaceChanges, + type PendingWorkspaceChanges +} from './workspace-layout-frame-pending' +import { layoutFrameApplyObserver } from './workspace-layout-frame-apply-observer' import { beginLayoutFramePositions, endLayoutFramePositions, @@ -67,156 +72,169 @@ export type WorkspaceLayoutFrameSync = LayoutFrameTerminalQueries & { close: () => void } -/** A bucket, not a host: the view read-back routes the workspaces a rebuild skips here. */ -const OTHER_WORKSPACES: ExecutionHostId = 'runtime:frame-sync-other-workspaces' - export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync { let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE - /** Workspace key → the frame the store shows. */ - let shown: Record = {} + /** Workspaces the store shows from frames; any other the frames draw takes a full build. */ + const shown = new Set() + /** Workspace key → what its records frames changed since the last flush. */ + const pending = new Map() let started = false let live = false let snapshotReceived = false let flushQueued = false let streamOpen = true - /** Workspaces whose pane trees the store may still lack: a structure frame came in, or a tab - * the window's own command held skipped one. */ + /** Workspaces whose pane trees the store may still lack: a build ran for them, or a tab the + * window's own command held skipped one. */ let paneTreeKeys = new Set() - const rebuild = (state: AppState, keys: string[]): void => { - // The view and content facts are this window's: read them back out of what it saves. - const rebuilt = new Set(keys) - const own = splitWorkspaceSessionByHost(buildWorkspaceSessionPayload(state), (key) => - rebuilt.has(key) ? LOCAL_EXECUTION_HOST_ID : OTHER_WORKSPACES - )[LOCAL_EXECUTION_HOST_ID]! - const { facts, desktopView } = loadWorkspaceLayoutWithoutReport( - LOCAL_EXECUTION_HOST_ID, - own, - nameBasedLoadContext() - ) - const heldFiles = new Set(state.openFiles.map((file) => `${file.worktreeId}\0${file.id}`)) - const built = buildLayoutFrameSlices({ - layouts: Object.fromEntries(keys.map((key) => [key, cache[key]!])), - facts, - view: desktopView, - holdsEditorFile: (worktreeId, fileId) => heldFiles.has(`${worktreeId}\0${fileId}`) - }) - const patch = buildLayoutFrameStorePatch( - state, - built, - keys, - isTabPaneTreeHeld, - takeTabRowFields, - HELD_DRAWN_TABS - ) + const show = (patch: LayoutFrameBuildPatch): void => { if (Object.keys(patch).length > 0) { store.setState(patch) } } + // Each change against the cache's copy it applies to, before the frame replaces it. + const noteFrame = (before: WorkspaceLayoutCache, frame: WorkspaceLayoutChangeFrame): void => { + switch (frame.type) { + case 'snapshot': + // A reopened stream's snapshot redraws every workspace: the window drew alone meanwhile. + if (snapshotReceived) { + shown.clear() + } + return + case 'workspace': + case 'removed': + // An older host's whole workspace: only a full build shows it. + shown.delete(frame.key) + return + case 'records': + notePendingRecordsFrame(pending, shown, before, cache, frame) + } + } + // Pane trees show at once; only a tab the window's own pane command holds waits. - const showPaneTrees = (state: AppState, drawn: (key: string) => boolean): void => { - let layouts = state.terminalLayoutsByTabId - const waiting = new Set() - for (const key of paneTreeKeys) { - const layout = cache[key] - if (!layout || !drawn(key)) { - continue - } - for (const tab of layout.tabs) { - const root = tab.terminal?.root - const current = root ? layouts[tab.entityId] : undefined - if (!root || !current) { - continue - } - if (isTabPaneTreeHeld(tab.entityId)) { - waiting.add(key) - } else if (stableJson(current.root) !== stableJson(root)) { - layouts = { ...layouts, [tab.entityId]: { ...current, root } } - } - } - } - paneTreeKeys = waiting - const patch = terminalLayoutsPatch(state, layouts) - if (Object.keys(patch).length > 0) { - store.setState(patch) - } + const showPaneTrees = (state: AppState): void => { + const shownTrees = terminalLayoutsWithFramePaneTrees( + state.terminalLayoutsByTabId, + cache, + paneTreeKeys, + framesDrawWorkspace(state) + ) + paneTreeKeys = shownTrees.waiting + show(terminalLayoutsPatch(state, shownTrees.layouts)) } - // Only panes' bindings changed: the pane trees take them, unless only a rebuild shows them so. - const showFrameBindings = (keys: string[]): void => { + // Only panes' bindings changed: the pane trees take them, unless only a build shows them so. + const showFrameBindings = (rebound: ReadonlyMap>): string[] => { const state = store.getState() let layouts = state.terminalLayoutsByTabId - const unshown = keys.filter((key) => { - const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!) - layouts = next ?? layouts - return next === null - }) - const patch = terminalLayoutsPatch(state, layouts) - if (Object.keys(patch).length > 0) { - store.setState(patch) - } - if (unshown.length > 0) { - rebuild(store.getState(), unshown) + const unshown: string[] = [] + for (const [key, tabIds] of rebound) { + const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!, tabIds) + if (next) { + layouts = next + } else { + unshown.push(key) + } } + show(terminalLayoutsPatch(state, layouts)) + return unshown } const flush = (): void => { flushQueued = false if (!live) { + pending.clear() return } const state = store.getState() const drawn = framesDrawWorkspace(state) - const nextShown: Record = {} - const structural: string[] = [] - const rebound: string[] = [] + const rowFieldsTaken = new Map>() + const holds: LayoutFrameBuildHolds = { + ...FRAME_BUILD_HOLDS, + newRowFields: (tabId) => { + const fields = takeTabRowFields(tabId) + if (fields) { + rowFieldsTaken.set(tabId, fields) + } + return fields + } + } + const changed: string[] = [] + const fullBuilt: string[] = [] + const workspaceBuilt: string[] = [] + const rebound = new Map>() const titleUpdates: TerminalTabTitleUpdate[] = [] for (const [key, layout] of Object.entries(cache)) { - const previous = shown[key] - if (layout === previous || !drawn(key)) { - if (previous) { - nextShown[key] = previous + const changes = pending.get(key) + if (!drawn(key)) { + // Frames the window did not draw: its next draw takes the full build. + if (changes) { + shown.delete(key) } continue } - // Against the store, not `shown`: a held frame leaves `shown` behind the pane trees drawn since. - paneTreeKeys.add(key) - const sameStructure = previous && structureJson(previous) === structureJson(layout) - if ( - sameStructure || - (previous && unboundStructureJson(previous) === unboundStructureJson(layout)) - ) { + if (shown.has(key) && !changes) { + continue + } + changed.push(key) + if (!shown.has(key)) { + fullBuilt.push(key) + } else if (changes?.rebuild) { + workspaceBuilt.push(key) + } else if (changes) { titleUpdates.push( ...liveTitleUpdates( - previous, + changes.titled, layout, (tabId) => state.terminalLayoutsByTabId[tabId]?.activeLeafId ) ) - if (!sameStructure) { - rebound.push(key) + if (changes.rebound.size > 0) { + rebound.set(key, changes.rebound) } - } else { - structural.push(key) } - nextShown[key] = layout } + pending.clear() // A workspace main stopped publishing keeps what the window shows; main has nothing to say. - shown = nextShown - if (structural.length > 0) { - rebuild(state, structural) + for (const key of shown) { + if (!(key in cache)) { + shown.delete(key) + } } - if (rebound.length > 0) { - showFrameBindings(rebound) + if (fullBuilt.length > 0) { + show(fullLayoutFrameBuildPatch(state, cache, fullBuilt, holds)) + } + if (rebound.size > 0) { + workspaceBuilt.push(...showFrameBindings(rebound)) + } + if (workspaceBuilt.length > 0) { + show(workspaceLayoutFrameBuildPatch(store.getState(), cache, workspaceBuilt, holds)) } if (titleUpdates.length > 0) { store.getState().updateTabTitles(titleUpdates) } - // A closed stream's cache is frozen: the window draws its own answers then. - if (streamOpen && paneTreeKeys.size > 0) { - showPaneTrees(store.getState(), framesDrawWorkspace(store.getState())) + for (const key of [...fullBuilt, ...workspaceBuilt]) { + shown.add(key) + // Against the store, not the frame: a held tab's tree may lag the pane trees drawn since. + paneTreeKeys.add(key) } + // A closed stream's cache is frozen: the window draws its own answers then. + if (!streamOpen) { + return + } + if (paneTreeKeys.size > 0) { + showPaneTrees(store.getState()) + } + layoutFrameApplyObserver()?.({ + before: state, + after: store.getState(), + cache, + changed, + fullBuilt, + workspaceBuilt, + rowFieldsTaken + }) } const scheduleFlush = (): void => { @@ -227,17 +245,25 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa } } + // A released tab shows main's copy: its workspace builds from the cache again. + const rebuildWorkspaces = (keys: Iterable): void => { + for (const key of keys) { + if (shown.has(key)) { + pendingWorkspaceChanges(pending, key).rebuild = true + } + } + scheduleFlush() + } + beginLayoutFramePositions() const stream = subscribeRuntimeLayout( { kind: 'local' }, {}, (frame) => { + const before = cache cache = applyLayoutFrame(cache, frame) + noteFrame(before, frame) if (frame.type === 'snapshot') { - // A reopened stream's snapshot redraws every workspace: the window drew alone meanwhile. - if (snapshotReceived) { - shown = {} - } snapshotReceived = true live = started } @@ -260,15 +286,10 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa // checks the store rather than assume the frame shows. (seq) => queueMicrotask(() => streamOpen && noteLayoutFramePosition(seq)) ) - // A released tab shows main's copy: its workspaces draw from the cache again. const stopHeldTabsListener = onDrawnTabsRedraw((workspaces) => { - if (!streamOpen) { - return + if (streamOpen) { + rebuildWorkspaces(workspaces) } - for (const key of workspaces) { - delete shown[key] - } - scheduleFlush() }) // Against the store: the window may have drawn over main's tree for this tab since. const stopReleaseListener = onTabPaneTreeRedraw((tabId) => { @@ -276,19 +297,18 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa if (!streamOpen) { return } - for (const [key, layout] of Object.entries(cache)) { - if (layout.tabs.some((tab) => tab.entityId === tabId)) { - paneTreeKeys.add(key) - } - } - scheduleFlush() + rebuildWorkspaces( + Object.entries(cache).flatMap(([key, layout]) => + layout.tabs.some((tab) => tab.entityId === tabId) ? [key] : [] + ) + ) }) // A workspace only main held (a CLI-started tab) shows on the window's first visit, so the // activation finds main's tab rather than seeding a second one. const stopHeldWorkspacesListener = onWorkspaceStartsDrawing( store, - () => Object.keys(cache).filter((key) => !(key in shown)), + () => Object.keys(cache).filter((key) => !shown.has(key)), () => streamOpen && scheduleFlush() ) @@ -298,7 +318,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa // Before its snapshot, the snapshot starts it; a stream that ends first leaves restore's. live = snapshotReceived // A re-run restore rebuilt the slices, so every workspace is shown again. - shown = {} + shown.clear() flush() }, close: () => { @@ -320,7 +340,10 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa } } -const HELD_DRAWN_TABS = { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld } +const FRAME_BUILD_HOLDS: Omit = { + keepsTerminalLayout: isTabPaneTreeHeld, + held: { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld } +} let startupFrames: WorkspaceLayoutFrameSync | null = null diff --git a/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts b/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts index a3a8f582660..f8af021a031 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts @@ -1,7 +1,7 @@ // Tab properties in a layout-owner build: main's pin, color or rename, drawn from its frame, must // look as the window's own action does today, and must restart the same. -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' @@ -24,6 +24,7 @@ import { createTestStore } from '../store/slices/store-test-helpers' import type { AppState } from '../store/types' import { layoutApplyTestTransport } from './layout-apply-test-transport.test-fixture' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' +import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture' // Main's answer to the window's layout commands, as a switch-on build sends them. vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => @@ -56,6 +57,15 @@ createStoreSessionMockApi() type Store = ReturnType +let check: ReturnType +beforeEach(() => { + check = installLayoutFrameApplyCheck() +}) +afterEach(() => { + check.uninstall() + expect(check.failures).toEqual([]) +}) + const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) /** diff --git a/src/renderer/src/store/workspace-layout-frame-store-view.ts b/src/renderer/src/store/workspace-layout-frame-store-view.ts new file mode 100644 index 00000000000..46b0c76b841 --- /dev/null +++ b/src/renderer/src/store/workspace-layout-frame-store-view.ts @@ -0,0 +1,182 @@ +// This window's view and content facts for some workspaces, read from the store's records the way +// the Loader reads them back from the window's save, so a workspace's frame slices build without +// serializing the window's session (design phase E2a). The save's own writers trim what it keeps +// (editor selection, groups, browser and scrollback state), so those run here too, over these +// workspaces' records only. + +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import { + BROWSER_TAB_LAYOUT_FIELDS, + BROWSER_TAB_WORKSPACE_FIELDS, + type DesktopLayoutView, + type LayoutContentFacts +} from '../../../shared/workspace-layout/workspace-layout-beside' +import { + childRecord, + omitStoredFields, + pickStoredFields +} from '../../../shared/workspace-layout/stored-record-fields' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { + buildPersistedBrowserPagesByWorkspace, + buildPersistedBrowserTabsByWorktree +} from '@/lib/workspace-session-browser-tabs' +import { buildEditorSessionData } from '@/lib/workspace-session' +import { buildPersistedUnifiedTabSessionData } from '@/lib/workspace-session-unified-tabs' +import type { AppState } from './types' + +export type LayoutFrameStoreViewState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'activeTabId' + | 'repos' + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'openFiles' + | 'editorDrafts' + | 'terminalLayoutsByTabId' + | 'localOnlyScrollbackByTabId' + | 'browserTabsByWorktree' + | 'browserPagesByWorkspace' + | 'remoteBrowserPageHandlesByPageId' + | 'activeTabIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeBrowserTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'markdownFrontmatterVisible' +> + +function pickKeys(map: Record, keys: Iterable): Record { + return Object.fromEntries([...keys].flatMap((key) => (key in map ? [[key, map[key]!]] : []))) +} + +function readPanes( + tabId: string, + layout: TerminalLayoutSnapshot, + view: DesktopLayoutView, + facts: LayoutContentFacts +): void { + view.panes[tabId] = { activeLeafId: layout.activeLeafId, expandedLeafId: layout.expandedLeafId } + for (const leafId of new Set([ + ...Object.keys(layout.buffersByLeafId ?? {}), + ...Object.keys(layout.scrollbackRefsByLeafId ?? {}) + ])) { + const buffer = layout.buffersByLeafId?.[leafId] + const scrollbackRef = layout.scrollbackRefsByLeafId?.[leafId] + facts.scrollback[leafId] = { + ...(buffer !== undefined ? { buffer } : {}), + ...(scrollbackRef !== undefined ? { scrollbackRef } : {}) + } + } +} + +/** The view and facts the frame slices of `keys` need; other workspaces' are left out. */ +export function layoutViewAndFactsOfStore( + state: LayoutFrameStoreViewState, + keys: readonly string[] +): { view: DesktopLayoutView; facts: LayoutContentFacts } { + const keySet = new Set(keys) + const editor = buildEditorSessionData( + state.openFiles.filter((file) => keySet.has(file.worktreeId)), + state.editorDrafts, + state.markdownFrontmatterVisible, + pickKeys(state.activeFileIdByWorktree, keys), + pickKeys(state.activeTabTypeByWorktree, keys) + ) + const unifiedTabsByWorktree = pickKeys(state.unifiedTabsByWorktree, keys) + const groupsByWorktree = pickKeys(state.groupsByWorktree, keys) + const layoutByWorktree = pickKeys(state.layoutByWorktree, keys) + const activeGroupIdByWorktree = pickKeys(state.activeGroupIdByWorktree, keys) + const tabBar = buildPersistedUnifiedTabSessionData({ + unifiedTabsByWorktree, + groupsByWorktree, + layoutByWorktree, + activeGroupIdByWorktree + }) + // The builders read only these workspaces' entries of the selection maps. + const view: DesktopLayoutView = { + activeRepoId: state.activeRepoId, + activeWorktreeId: state.activeWorktreeId, + activeTabId: state.activeTabId, + activeTabIdByWorktree: state.activeTabIdByWorktree, + activeGroupIdByWorktree: tabBar.activeGroupIdByWorktree, + activeFileIdByWorktree: editor.activeFileIdByWorktree, + activeBrowserTabIdByWorktree: state.activeBrowserTabIdByWorktree, + activeTabTypeByWorktree: editor.activeTabTypeByWorktree, + markdownFrontmatterVisible: editor.markdownFrontmatterVisible, + groups: {}, + lastFocusedAt: {}, + panes: {}, + editorDrafts: {} + } + const tabsByWorktree = pickKeys(state.tabsByWorktree, keys) + const terminalTabIds = Object.values(tabsByWorktree).flatMap((rows) => rows.map((row) => row.id)) + const terminalLayoutsByTabId = pickKeys(state.terminalLayoutsByTabId, terminalTabIds) + const localOnlyScrollbackByTabId = pickKeys(state.localOnlyScrollbackByTabId, terminalTabIds) + // The save's scrollback trim reads only the rows and their pane trees. + const terminals = pruneLocalTerminalScrollbackBuffers( + { ...view, tabsByWorktree, terminalLayoutsByTabId, localOnlyScrollbackByTabId }, + state.repos + ) + const browserTabs = buildPersistedBrowserTabsByWorktree( + pickKeys(state.browserTabsByWorktree, keys) + ) + const browserTabIds = Object.values(browserTabs ?? {}).flatMap((tabs) => + tabs.map((tab) => tab.id) + ) + const facts: LayoutContentFacts = { + localOnlyScrollbackByTabId: terminals.localOnlyScrollbackByTabId, + browserPagesByWorkspace: buildPersistedBrowserPagesByWorkspace( + pickKeys(state.browserPagesByWorkspace, browserTabIds), + state.remoteBrowserPageHandlesByPageId + ), + tabLabels: {}, + terminalRows: {}, + scrollback: {}, + browserTabs: {} + } + for (const key of keys) { + const entries = tabBar.unifiedTabs?.[key] ?? [] + const terminalEntries = new Map( + entries.flatMap((entry) => + entry.contentType === 'terminal' ? [[entry.entityId, entry]] : [] + ) + ) + for (const entry of entries) { + if (entry.contentType !== 'terminal') { + childRecord(facts.tabLabels, key)[entry.id] = entry.label + } + if (entry.lastFocusedAt !== undefined) { + childRecord(view.lastFocusedAt, key)[entry.id] = entry.lastFocusedAt + } + } + // The Loader keeps a repeated group id's first selection. + for (const group of tabBar.tabGroups?.[key] ?? []) { + childRecord(view.groups, key)[group.id] ??= { + activeTabId: group.activeTabId, + ...pickStoredFields(group, ['recentTabIds']) + } + } + for (const row of tabsByWorktree[key] ?? []) { + facts.terminalRows[row.id] = { + title: terminalEntries.get(row.id)?.label ?? row.title, + ...pickStoredFields(row, ['generation']) + } + const layout = terminals.terminalLayoutsByTabId[row.id] + if (layout) { + readPanes(row.id, layout, view, facts) + } + } + for (const tab of browserTabs?.[key] ?? []) { + childRecord(facts.browserTabs, key)[tab.id] = omitStoredFields(tab, [ + ...BROWSER_TAB_LAYOUT_FIELDS, + ...BROWSER_TAB_WORKSPACE_FIELDS + ]) + } + } + return { view, facts } +} diff --git a/tests/e2e/terminal-account-restart.unit.test.ts b/tests/e2e/terminal-account-restart.unit.test.ts index 98cf2fa76d6..9d7ed0b036c 100644 --- a/tests/e2e/terminal-account-restart.unit.test.ts +++ b/tests/e2e/terminal-account-restart.unit.test.ts @@ -177,9 +177,10 @@ function installRestartHarness( pendingWrite: null, quitFlushStarted: false, writeGeneration: 0, - lastDurableWriteGeneration: 0 + lastDurableWriteGeneration: 0, + ptyBindingGenerations: new Map() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation, the per-pane binding generations and the session partitions from these fakes. const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters< typeof PtyBindingPersistenceOperations > @@ -264,7 +265,7 @@ describe('account restart through renderer connection and host spawn', () => { registerWithFakes(mainWindow, host.runtime, host.store) rendererState = createInitialStoreState(() => rendererState) rendererState.tabsByWorktree[worktreeId][0].ptyId = 'pty-old' - rendererState.ptyIdsByTabId[tabId] = ['pty-old'] + rendererState.ptyIdsByTabId = { ...rendererState.ptyIdsByTabId, [tabId]: ['pty-old'] } rendererState.sleepingAgentSessionsByPaneKey[paneKey] = { paneKey, tabId, @@ -276,7 +277,12 @@ describe('account restart through renderer connection and host spawn', () => { capturedAt: 1, updatedAt: 1 } - rendererState.terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'pty-old' + const layouts = rendererState.terminalLayoutsByTabId ?? {} + const layout = layouts[tabId]! + rendererState.terminalLayoutsByTabId = { + ...layouts, + [tabId]: { ...layout, ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: 'pty-old' } } + } await installTerminalTestGlobals() installIpcPtyWindow(window, {}) window.api.pty.claimViewport = vi.fn() @@ -310,7 +316,7 @@ describe('account restart through renderer connection and host spawn', () => { paneTransportsRef: { current: transports }, clearTabPtyId: vi.fn(() => { rendererState.tabsByWorktree[worktreeId][0].ptyId = null - rendererState.ptyIdsByTabId[tabId] = [] + rendererState.ptyIdsByTabId = { ...rendererState.ptyIdsByTabId, [tabId]: [] } }) }) rendererState.pendingPaneAccountRestarts = { 'pty-old': 'codex' }