diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index e64747ca6c1..aedbce55f1e 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -118,3 +118,4 @@ 2 src/renderer/src/store/terminals/workspace-terminal-hydration.ts 2 src/renderer/src/store/terminals/workspace-terminal-reconnect.ts 4 src/renderer/src/store/workspace-layout-frame-slices.ts +12 src/renderer/src/store/workspace-layout-frame-store-patch.ts diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts index 3da45244660..9ab3e236373 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts @@ -1,4 +1,6 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { showStartupLayoutFrames } from '../runtime/workspace-layout-frame-sync' /** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */ export function reconcileHydratedWorkspaceTabModels( @@ -11,5 +13,11 @@ export function reconcileHydratedWorkspaceTabModels( if (reconciled.length > 0) { reconcileWorktreeTabModels(reconciled) } + // Switch point: from main's first layout snapshot on, the frames' workspaces replace what restore + // built for them. Records frames do not carry (sleeping agents, closed tabs, default-tab marks, + // what reattaches) and the view stay restore's. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + showStartupLayoutFrames() + } return reconciled } diff --git a/src/renderer/src/lib/session-write-subscriber-activity.test.ts b/src/renderer/src/lib/session-write-subscriber-activity.test.ts new file mode 100644 index 00000000000..0963071488a --- /dev/null +++ b/src/renderer/src/lib/session-write-subscriber-activity.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore, type AppState } from '@/store' +import { createSessionWriteSubscriber } from './session-write-subscriber' +import { isWindowSessionWriteBusy } from './window-session-write-activity' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +describe('session write activity in a layout-owner build', () => { + let initial: AppState + let dispose: (() => void) | undefined + beforeEach(() => { + initial = useAppStore.getState() + vi.useFakeTimers() + }) + afterEach(() => { + dispose?.() + dispose = undefined + vi.useRealTimers() + useAppStore.setState(initial, true) + }) + + it('is busy from a change until main acknowledges its write', async () => { + let acknowledge!: () => void + const persist = vi.fn( + () => + new Promise((resolve) => { + acknowledge = resolve + }) + ) + dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + // The first write after the gate opens. + await vi.advanceTimersByTimeAsync(200) + acknowledge() + await vi.advanceTimersByTimeAsync(0) + expect(isWindowSessionWriteBusy()).toBe(false) + + useAppStore.setState({ activeTabId: 'tab-2' }) + expect(isWindowSessionWriteBusy()).toBe(true) + await vi.advanceTimersByTimeAsync(200) + expect(persist).toHaveBeenCalledTimes(2) + expect(isWindowSessionWriteBusy()).toBe(true) + acknowledge() + await vi.advanceTimersByTimeAsync(0) + expect(isWindowSessionWriteBusy()).toBe(false) + }) + + it('is busy before the window may save, and after a failed write until it resends', async () => { + const persist = vi.fn(() => Promise.reject(new Error('main unavailable'))) + dispose = createSessionWriteSubscriber({ + store: useAppStore, + persist, + onPersistError: () => {} + }) + expect(isWindowSessionWriteBusy()).toBe(true) + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + await vi.advanceTimersByTimeAsync(200) + expect(persist).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + expect(isWindowSessionWriteBusy()).toBe(true) + }) + + it('is idle once disposed', () => { + dispose = createSessionWriteSubscriber({ store: useAppStore, persist: vi.fn() }) + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + expect(isWindowSessionWriteBusy()).toBe(true) + dispose() + dispose = undefined + expect(isWindowSessionWriteBusy()).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index ba436a90178..cd6ebceccb3 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -4,6 +4,8 @@ import type { WorkspaceSessionPatch } from '../../../shared/workspace-session-st import { SESSION_RELEVANT_FIELDS, shouldPersistWorkspaceSession } from './workspace-session' import { buildWorkspaceSessionPatch } from './workspace-session-patch' import { createWorktreeTabBucketProjection } from './worktree-tab-bucket-projection' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { reportWindowSessionWriteActivity } from './window-session-write-activity' type SessionRelevantField = (typeof SESSION_RELEVANT_FIELDS)[number] type TabsByWorktree = AppState['tabsByWorktree'] @@ -124,10 +126,21 @@ export function createSessionWriteSubscriber({ const pendingChangedFields = new Set() const terminalTabsProjection = createTerminalSessionTabsProjection() const unifiedTabsProjection = createUnifiedSessionTabsProjection() + // Switch point (each report below, inline so a window-owned build keeps none of it): layout + // frames wait while this window owes main a write, including before it may save at all. + const owesWrite = (): boolean => + !disposed && + (timer !== null || + writing || + pendingChangedFields.size > 0 || + !shouldPersistWorkspaceSession(store.getState())) const flushPendingWrite = (): void => { timer = null if (disposed || writing || pendingChangedFields.size === 0) { + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + reportWindowSessionWriteActivity(owesWrite()) + } return } // Why: rebuild from the freshest store state rather than the snapshot @@ -148,6 +161,9 @@ export function createSessionWriteSubscriber({ pendingChangedFields.clear() const patch = buildWorkspaceSessionPatch(fresh, changed) if (Object.keys(patch).length === 0) { + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + reportWindowSessionWriteActivity(owesWrite()) + } return } writing = true @@ -165,6 +181,10 @@ export function createSessionWriteSubscriber({ } else if (pendingChangedFields.size > 0) { armFlushTimer() } + // A failed write stays owed, so structure frames wait for the next store wake's resend. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + reportWindowSessionWriteActivity(owesWrite()) + } } try { const result = persist({ patch }) @@ -189,6 +209,9 @@ export function createSessionWriteSubscriber({ clearTimeout(timer) } timer = setTimeout(flushPendingWrite, debounceMs) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + reportWindowSessionWriteActivity(owesWrite()) + } } /** @@ -219,6 +242,9 @@ export function createSessionWriteSubscriber({ const evaluateSessionState = (state: AppState): void => { if (!shouldPersistWorkspaceSession(state)) { + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + reportWindowSessionWriteActivity(owesWrite()) + } return } // Why: this fires on every store write and almost none of them touch a session field. Scan @@ -281,5 +307,8 @@ export function createSessionWriteSubscriber({ clearTimeout(timer) } pendingChangedFields.clear() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + reportWindowSessionWriteActivity(owesWrite()) + } } } diff --git a/src/renderer/src/lib/window-session-write-activity.ts b/src/renderer/src/lib/window-session-write-activity.ts new file mode 100644 index 00000000000..519d19e4388 --- /dev/null +++ b/src/renderer/src/lib/window-session-write-activity.ts @@ -0,0 +1,30 @@ +// Whether this window owes main a session write: one is scheduled or not yet acknowledged. Fed by +// the session write subscriber in a layout-owner build only; layout frames wait while it is busy, +// so a frame never replaces a change main has not received yet. + +let busy = false +let idleListener: (() => void) | null = null + +export function reportWindowSessionWriteActivity(nextBusy: boolean): void { + if (busy === nextBusy) { + return + } + busy = nextBusy + if (!busy) { + idleListener?.() + } +} + +export function isWindowSessionWriteBusy(): boolean { + return busy +} + +/** The one listener: the window's layout frame sync. */ +export function onWindowSessionWriteIdle(listener: () => void): () => void { + idleListener = listener + return () => { + if (idleListener === listener) { + idleListener = null + } + } +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts new file mode 100644 index 00000000000..626194c7508 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts @@ -0,0 +1,248 @@ +import { 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' +import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-legacy-profile.test-fixture' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { + localDesktopSession, + WORKSPACE_LAYOUT_PROFILES +} from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture' +import { + publishWorkspaceLayout, + type PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +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 { reportWindowSessionWriteActivity } from '../lib/window-session-write-activity' +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 { 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) } +}) + +type StreamDouble = { + onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null + onStatus: ((status: { state: string }) => void) | null + close: () => void +} +const stream = vi.hoisted((): StreamDouble => ({ onFrame: null, onStatus: null, close: () => {} })) +vi.mock('./runtime-layout-client', () => ({ + subscribeRuntimeLayout: ( + _target: unknown, + _params: unknown, + onFrame: (frame: WorkspaceLayoutChangeFrame) => void, + onStatus: (status: { state: string }) => void + ) => { + stream.onFrame = onFrame + stream.onStatus = onStatus + return { close: stream.close } + } +})) + +createStoreSessionMockApi() + +const SLICES: (keyof LayoutFrameSlices)[] = [ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'activeTabIdByWorktree', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'activeGroupIdByWorktree', + 'layoutByWorktree', + 'openFiles', + 'editorDrafts', + 'markdownFrontmatterVisible', + 'activeFileIdByWorktree', + 'browserTabsByWorktree', + 'browserPagesByWorkspace', + 'remoteBrowserPageHandlesByPageId', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree' +] + +/** What main publishes for one stored partition. */ +function published( + hostId: ExecutionHostId, + session: WorkspaceSessionState +): Record { + const { layout } = loadWorkspaceLayoutWithoutReport(hostId, session, nameBasedLoadContext()) + return Object.fromEntries( + Object.entries(layout.workspaces).map(([key, workspace]) => [ + key, + publishWorkspaceLayout(workspace, hostId) + ]) + ) +} + +function snapshot(layouts: Record): WorkspaceLayoutChangeFrame { + return { + type: 'snapshot', + workspaces: Object.entries(layouts).map(([key, layout]) => ({ key, layout })) + } +} + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) + +async function bootWithFrames(hostId: ExecutionHostId, session: WorkspaceSessionState) { + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId, session }]) + const sync = openWorkspaceLayoutFrameSync(store) + const layouts = published(hostId, session) + stream.onFrame!(snapshot(layouts)) + return { store, sync, layouts } +} + +/** Fixtures whose frames differ from the restore, as the frame parity test lists. */ +const FRAME_DIFFERENCE_FIXTURES = new Set([ + 'row without a tab-bar entry or a terminal', + 'legacy surface tombstone on a pane still showing its terminal', + 'tab-bar label differing from the row title', + 'row without a tab-bar entry, still bound to a terminal', + 'legacy split row sharing one pane’s terminal with a tab-bar tab', + 'session from before the tab bar, with an open file' +]) + +beforeEach(() => { + reportWindowSessionWriteActivity(false) +}) + +describe('the window drawn from layout frames', () => { + it.each( + [...WORKSPACE_LAYOUT_PROFILES, ...LEGACY_WORKSPACE_LAYOUT_PROFILES] + .filter((profile) => !FRAME_DIFFERENCE_FIXTURES.has(profile.name)) + .map((profile) => [profile.name, profile] as const) + )('startup from the first snapshot changes nothing restore showed: %s', async (_, profile) => { + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId: profile.hostId, session: profile.build() }]) + const before = store.getState() + const sync = openWorkspaceLayoutFrameSync(store) + stream.onFrame!(snapshot(published(profile.hostId, profile.build()))) + sync.start() + const after = store.getState() + expect(SLICES.filter((slice) => after[slice] !== before[slice])).toEqual([]) + sync.close() + }) + + 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) + sync.start() + store.setState({ + tabsByWorktree: { + ...store.getState().tabsByWorktree, + [GIT_KEY]: store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => + tab.id === 'tab-shell' + ? { + ...tab, + ptyId: 'live-pty', + pendingActivationSpawn: true, + restoredFromSession: undefined + } + : tab + ) + } + }) + 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 }) + await flushMicrotasks() + const shell = store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')! + expect(shell).toMatchObject({ + customTitle: 'renamed', + ptyId: 'live-pty', + pendingActivationSpawn: true + }) + // Restore marks every row it builds; a row whose terminal bound since must not get it back. + expect(shell).not.toHaveProperty('restoredFromSession') + sync.close() + }) + + it('holds a structure frame while the window owes main a write, then shows it', async () => { + const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) + sync.start() + reportWindowSessionWriteActivity(true) + 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 }) + await flushMicrotasks() + const shellTitle = () => + store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.customTitle + expect(shellTitle()).not.toBe('renamed') + reportWindowSessionWriteActivity(false) + await flushMicrotasks() + expect(shellTitle()).toBe('renamed') + sync.close() + }) + + it('shows a live title change of the focused pane at once, even while a write is owed', async () => { + const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) + sync.start() + reportWindowSessionWriteActivity(true) + const titled = structuredClone(layouts[GIT_KEY]!) + const shell = titled.tabs.find((tab) => tab.id === 'tab-shell')! + const focused = store.getState().terminalLayoutsByTabId['tab-shell']!.activeLeafId + shell.terminal!.panes = shell.terminal!.panes.map((pane) => ({ + ...pane, + liveTitle: pane.leafId === focused ? 'vim notes.md' : 'other pane' + })) + const before = store.getState() + stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: titled }) + await flushMicrotasks() + const after = store.getState() + expect(after.tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.title).toBe( + 'vim notes.md' + ) + expect(after.groupsByWorktree).toBe(before.groupsByWorktree) + expect(after.terminalLayoutsByTabId).toBe(before.terminalLayoutsByTabId) + sync.close() + }) + + it('leaves a paired server’s workspaces to the mirror', async () => { + const profile = WORKSPACE_LAYOUT_PROFILES.find( + (entry) => entry.name === 'remote server partition' + )! + const { store, sync, layouts } = 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: [] } }) + await flushMicrotasks() + expect(SLICES.filter((slice) => store.getState()[slice] !== before[slice])).toEqual([]) + sync.close() + }) + + it('leaves restore in place when the stream ends before its snapshot', async () => { + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId: 'local', session: localDesktopSession() }]) + const before = store.getState() + const sync = openWorkspaceLayoutFrameSync(store) + sync.start() + stream.onStatus!({ state: 'refused' }) + await flushMicrotasks() + expect(SLICES.filter((slice) => store.getState()[slice] !== before[slice])).toEqual([]) + sync.close() + }) + + it('started before its snapshot, shows the snapshot when it arrives', async () => { + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId: 'local', session: localDesktopSession() }]) + const sync = openWorkspaceLayoutFrameSync(store) + sync.start() + const layouts = published('local', localDesktopSession()) + layouts[GIT_KEY]!.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'from main' + stream.onFrame!(snapshot(layouts)) + await flushMicrotasks() + expect( + store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.customTitle + ).toBe('from main') + sync.close() + }) +}) diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts new file mode 100644 index 00000000000..865155555b3 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -0,0 +1,241 @@ +// The window's layout slices drawn from main's layout stream (design phase E2b), in a layout-owner +// build only. Main publishes every workspace it holds; this window shows a frame's workspace when +// it writes that workspace to a partition this machine owns (a paired server's stay the mirror's) +// and already holds it (startup restore keeps only workspaces the catalog knows). +// +// Frames wait while this window owes main a session write: until main has the window's own change, +// a frame would put back what the window just changed. Main sends a write's frames before it +// acknowledges the write, so the cache is current by the time the window is idle again. +// A frame that changes only live terminal titles never waits: it touches no structure. + +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspace-layout-cache' +import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' +import { buildLayoutFrameSlices, shownLiveTitle } from '@/store/workspace-layout-frame-slices' +import { buildLayoutFrameStorePatch } from '@/store/workspace-layout-frame-store-patch' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { buildHostIdByWorktreeId } from '@/lib/workspace-session-host-persistence' +import { + splitWorkspaceSessionByHost, + type HostIdByWorktreeId +} from '@/lib/workspace-session-host-split' +import { + isWindowSessionWriteBusy, + onWindowSessionWriteIdle +} from '@/lib/window-session-write-activity' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + 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 { subscribeRuntimeLayout } from './runtime-layout-client' + +type FrameSyncStore = { + getState: () => AppState + setState: (patch: Partial) => void +} + +export type WorkspaceLayoutFrameSync = { + /** Shows every workspace the frames own once the snapshot is in, then keeps them current. */ + start: () => void + 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' + +const structureJsonByLayout = new WeakMap() + +/** A frame's JSON without live titles, kept per frame: title frames arrive at spinner rate. */ +function structureJson(layout: PublishedWorkspaceLayout): string { + let json = structureJsonByLayout.get(layout) + if (json === undefined) { + json = withoutLiveTitles(layout) + structureJsonByLayout.set(layout, json) + } + return json +} + +function withoutLiveTitles(layout: PublishedWorkspaceLayout): string { + return stableJson({ + ...layout, + tabs: layout.tabs.map((tab) => + tab.terminal + ? { + ...tab, + terminal: { + ...tab.terminal, + panes: tab.terminal.panes.map(({ liveTitle: _liveTitle, ...pane }) => pane) + } + } + : tab + ) + }) +} + +/** Title changes between two frames of one workspace, for the pane each tab shows. */ +function liveTitleUpdates( + shown: PublishedWorkspaceLayout, + 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) + if (!tab.terminal || !shownTab) { + continue + } + const focused = focusedLeafId(tab.entityId) + const title = shownLiveTitle(tab, focused) + if (title !== undefined && title !== shownLiveTitle(shownTab, focused)) { + updates.push({ tabId: tab.entityId, title }) + } + } + return updates +} + +function holdsWorkspace(state: AppState, key: string): boolean { + return key in state.groupsByWorktree || key in state.unifiedTabsByWorktree +} + +export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync { + let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE + /** Workspace key → the frame the store shows. */ + let shown: Record = {} + let started = false + let live = false + let snapshotReceived = false + let flushQueued = false + + 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 built = buildLayoutFrameSlices({ + layouts: Object.fromEntries(keys.map((key) => [key, cache[key]!])), + facts, + view: desktopView + }) + const patch = buildLayoutFrameStorePatch(state, built, keys) + if (Object.keys(patch).length > 0) { + store.setState(patch) + } + } + + const flush = (): void => { + flushQueued = false + if (!live) { + return + } + const state = store.getState() + let hostIdOf: HostIdByWorktreeId | null = null + const ownedByPairedServer = (key: string): boolean => { + hostIdOf ??= buildHostIdByWorktreeId(state) + return parseExecutionHostId(hostIdOf(key))?.kind === 'runtime' + } + const busy = isWindowSessionWriteBusy() + const nextShown: Record = {} + const structural: string[] = [] + const titleUpdates: TerminalTabTitleUpdate[] = [] + for (const [key, layout] of Object.entries(cache)) { + const previous = shown[key] + if (layout === previous || !holdsWorkspace(state, key) || ownedByPairedServer(key)) { + if (previous) { + nextShown[key] = previous + } + continue + } + if (previous && structureJson(previous) === structureJson(layout)) { + titleUpdates.push( + ...liveTitleUpdates( + previous, + layout, + (tabId) => state.terminalLayoutsByTabId[tabId]?.activeLeafId + ) + ) + nextShown[key] = layout + } else if (busy) { + if (previous) { + nextShown[key] = previous + } + } else { + structural.push(key) + nextShown[key] = layout + } + } + // A workspace main stopped publishing keeps what the window shows; main has nothing to say. + shown = nextShown + if (structural.length > 0) { + rebuild(state, structural) + } + if (titleUpdates.length > 0) { + store.getState().updateTabTitles(titleUpdates) + } + } + + const scheduleFlush = (): void => { + if (live && !flushQueued) { + flushQueued = true + // Coalesces the frames of one task; also lets a write that is about to start report busy. + queueMicrotask(flush) + } + } + + const stream = subscribeRuntimeLayout( + { kind: 'local' }, + {}, + (frame) => { + cache = applyLayoutFrame(cache, frame) + if (frame.type === 'snapshot') { + snapshotReceived = true + live = started + } + scheduleFlush() + }, + (status) => { + if (status.state === 'lost' || status.state === 'refused') { + console.warn('[layout-owner] layout stream stopped:', status) + } + } + ) + const stopIdleListener = onWindowSessionWriteIdle(scheduleFlush) + + return { + start: () => { + started = true + // 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 = {} + flush() + }, + close: () => { + live = false + stopIdleListener() + stream.close() + } + } +} + +let startupFrames: WorkspaceLayoutFrameSync | null = null + +/** After restore: the window's one layout stream shows the frames' workspaces from now on. */ +export function showStartupLayoutFrames(): void { + // Kept for the document's life: a local stream ends only with main, and a re-run boot reuses it. + startupFrames ??= openWorkspaceLayoutFrameSync(useAppStore) + startupFrames.start() +} diff --git a/src/renderer/src/store/workspace-layout-frame-slices.ts b/src/renderer/src/store/workspace-layout-frame-slices.ts index c481979131c..7534beeb704 100644 --- a/src/renderer/src/store/workspace-layout-frame-slices.ts +++ b/src/renderer/src/store/workspace-layout-frame-slices.ts @@ -14,7 +14,10 @@ import type { LayoutContentFacts } from '../../../shared/workspace-layout/workspace-layout-beside' import { workspaceLayoutModelFromPublished } from '../../../shared/workspace-layout/workspace-layout-from-published' -import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import type { + PublishedTab, + PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { projectHydratedBrowserSession } from './slices/browser/browser-hydration-state' @@ -63,6 +66,14 @@ const NOTHING_CARRIED: CarriedSessionFields = { } /** A terminal's title is the live title of the pane this view focuses, when main reports one. */ +export function shownLiveTitle( + tab: PublishedTab, + focusedLeafId: string | null | undefined +): string | undefined { + const panes = tab.terminal?.panes ?? [] + return (panes.find((pane) => pane.leafId === focusedLeafId) ?? panes[0])?.liveTitle +} + function withLiveTitles( layouts: WorkspaceLayoutCache, facts: LayoutContentFacts, @@ -70,9 +81,7 @@ function withLiveTitles( ): LayoutContentFacts { const terminalRows = { ...facts.terminalRows } for (const tab of Object.values(layouts).flatMap((layout) => layout.tabs)) { - const panes = tab.terminal?.panes ?? [] - const focused = view.panes[tab.entityId]?.activeLeafId - const liveTitle = (panes.find((pane) => pane.leafId === focused) ?? panes[0])?.liveTitle + const liveTitle = shownLiveTitle(tab, view.panes[tab.entityId]?.activeLeafId) if (liveTitle !== undefined) { terminalRows[tab.entityId] = { ...terminalRows[tab.entityId], title: liveTitle } } diff --git a/src/renderer/src/store/workspace-layout-frame-store-patch.test.ts b/src/renderer/src/store/workspace-layout-frame-store-patch.test.ts new file mode 100644 index 00000000000..b82de545ea6 --- /dev/null +++ b/src/renderer/src/store/workspace-layout-frame-store-patch.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +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' +import { localDesktopSession } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture' +import { + publishWorkspaceLayout, + type PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +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 './slices/store-session-test-harness' +import { createTestStore } from './slices/store-test-helpers' +import type { AppState } from './types' +import { buildLayoutFrameSlices } from './workspace-layout-frame-slices' +import { buildLayoutFrameStorePatch } from './workspace-layout-frame-store-patch' + +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) } +}) + +createStoreSessionMockApi() + +async function restored(): Promise<{ + state: AppState + session: WorkspaceSessionState + frame: PublishedWorkspaceLayout +}> { + const session = localDesktopSession() + const state = await bootWindowFromPartitions(createTestStore(), [{ hostId: 'local', session }]) + const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext()) + return { state, session, frame: publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local') } +} + +/** The patch that shows `frame` for the git workspace, with the stored session's view and facts. */ +function patchFor( + state: AppState, + session: WorkspaceSessionState, + frame: PublishedWorkspaceLayout +) { + const { facts, desktopView } = loadWorkspaceLayoutWithoutReport( + 'local', + session, + nameBasedLoadContext() + ) + const built = buildLayoutFrameSlices({ layouts: { [GIT_KEY]: frame }, facts, view: desktopView }) + return buildLayoutFrameStorePatch(state, built, [GIT_KEY]) +} + +function withoutTab(frame: PublishedWorkspaceLayout, tabId: string): PublishedWorkspaceLayout { + return { + ...frame, + groups: frame.groups.map((group) => ({ + ...group, + tabIds: group.tabIds.filter((id) => id !== tabId) + })), + tabs: frame.tabs.filter((tab) => tab.id !== tabId) + } +} + +describe('laying frame slices over the store', () => { + it('changes nothing for the frame the store already shows', async () => { + const { state, session, frame } = await restored() + expect(patchFor(state, session, frame)).toEqual({}) + }) + + it('drops a terminal tab main closed, with its pane layout', async () => { + const { state, session, frame } = await restored() + const patch = patchFor(state, session, withoutTab(frame, 'tab-shell')) + expect(patch.tabsByWorktree?.[GIT_KEY]?.map((tab) => tab.id)).not.toContain('tab-shell') + expect(patch.terminalLayoutsByTabId).not.toHaveProperty('tab-shell') + expect(patch.unifiedTabsByWorktree?.[GIT_KEY]?.map((tab) => tab.id)).not.toContain('tab-shell') + }) + + it('keeps an open file’s editor state and place, and drops a file main closed', async () => { + const { state, session, frame } = await restored() + const file = state.openFiles.find((entry) => entry.worktreeId === GIT_KEY)! + const edited = { ...state, openFiles: [{ ...file, isDirty: true }] } + expect(patchFor(edited, session, frame).openFiles).toBeUndefined() + const closed = patchFor(edited, session, { + ...withoutTab(frame, 'editor:src/app.ts'), + editorFiles: [] + }) + expect(closed.openFiles).toEqual([]) + }) + + it('keeps a browser tab’s page state, and drops the pages of a tab main closed', async () => { + const { state, session, frame } = await restored() + const browser = state.browserTabsByWorktree[GIT_KEY]![0]! + const browsing = { + ...state, + browserTabsByWorktree: { [GIT_KEY]: [{ ...browser, url: 'https://example.com/next' }] } + } + expect(patchFor(browsing, session, frame).browserTabsByWorktree).toBeUndefined() + const closed = patchFor(browsing, session, { + ...withoutTab(frame, 'browser-1'), + browserTabs: [] + }) + expect(closed.browserPagesByWorkspace).not.toHaveProperty('browser-1') + }) +}) diff --git a/src/renderer/src/store/workspace-layout-frame-store-patch.ts b/src/renderer/src/store/workspace-layout-frame-store-patch.ts new file mode 100644 index 00000000000..10ece56b42d --- /dev/null +++ b/src/renderer/src/store/workspace-layout-frame-store-patch.ts @@ -0,0 +1,238 @@ +// Lays slices rebuilt from layout frames over the store for the workspaces frames replaced. A record +// equal to the store's keeps the store's object, so an unchanged frame re-renders nothing; a +// record the store already holds keeps what only this window knows about it (a terminal row's +// in-flight spawn and recovery state, an open file's editor state, a browser tab's page state). + +import { BROWSER_TAB_LAYOUT_FIELDS } from '../../../shared/workspace-layout/workspace-layout-beside' +import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { AppState } from './types' +import type { LayoutFrameSlices } from './workspace-layout-frame-slices' + +/** Row fields this window owns: what its mounted panes are doing, and which terminal each row + * binds (main publishes the saved binding; the live one stays the window's until main owns it). */ +const WINDOW_TERMINAL_TAB_FIELDS = [ + 'ptyId', + 'pendingActivationSpawn', + 'recovery', + 'restoredFromSession', + 'agentLaunchPane' +] as const satisfies readonly (keyof TerminalTab)[] + +type KeyedSlice = + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeBrowserTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'browserTabsByWorktree' + +export type LayoutFrameStoreState = Pick< + AppState, + KeyedSlice | 'terminalLayoutsByTabId' | 'openFiles' | 'browserPagesByWorkspace' +> & + Pick + +function same(a: unknown, b: unknown): boolean { + return a === b || stableJson(a) === stableJson(b) +} + +function withoutWindowFields(tab: TerminalTab): Partial { + const fields: Partial = { ...tab } + for (const field of WINDOW_TERMINAL_TAB_FIELDS) { + delete fields[field] + } + return fields +} + +function mergeTerminalRow(existing: TerminalTab | undefined, built: TerminalTab): TerminalTab { + if (!existing) { + return built + } + if (same(withoutWindowFields(existing), withoutWindowFields(built))) { + return existing + } + const merged: TerminalTab = { ...built, ptyId: existing.ptyId } + for (const field of WINDOW_TERMINAL_TAB_FIELDS) { + if (field === 'ptyId') { + continue + } + if (existing[field] === undefined) { + delete merged[field] + } else { + Object.assign(merged, { [field]: existing[field] }) + } + } + return merged +} + +/** Each built record, or the store's when equal; the store's array when every record is. */ +function mergeById( + existing: T[] | undefined, + built: readonly T[] | undefined, + merge: (existing: T | undefined, built: T) => T = (current, next) => + current && same(current, next) ? current : next +): T[] | undefined { + if (!built) { + return undefined + } + const byId = new Map(existing?.map((record) => [record.id, record])) + const merged = built.map((record) => merge(byId.get(record.id), record)) + return existing && + existing.length === merged.length && + merged.every((record, index) => record === existing[index]) + ? existing + : merged +} + +const isEmpty = (value: unknown): boolean => + value === undefined || value === null || (Array.isArray(value) && value.length === 0) + +function setKey( + map: Record, + key: string, + value: T | undefined +): Record | null { + // A workspace with no records or selection of a kind may be listed empty or left out. + if (isEmpty(map[key]) && isEmpty(value)) { + return null + } + if (map[key] === value || (value !== undefined && key in map && same(map[key], value))) { + return null + } + if (value === undefined) { + if (!(key in map)) { + return null + } + const { [key]: _removed, ...rest } = map + return rest + } + return { ...map, [key]: value } +} + +/** + * The store patch that shows `built` for `keys`; slices of other workspaces are untouched. + * Returns only the slices that changed. + */ +export function buildLayoutFrameStorePatch( + state: LayoutFrameStoreState, + built: LayoutFrameSlices, + keys: readonly string[] +): Partial { + const patch: Partial = {} + const keyed = (slice: KeyedSlice, value: (key: string) => unknown): void => { + let next: Record = state[slice] + for (const key of keys) { + next = setKey(next, key, value(key)) ?? next + } + if (next !== state[slice]) { + Object.assign(patch, { [slice]: next }) + } + } + + keyed('tabsByWorktree', (key) => + mergeById(state.tabsByWorktree[key], built.tabsByWorktree[key], mergeTerminalRow) + ) + keyed('unifiedTabsByWorktree', (key) => + mergeById(state.unifiedTabsByWorktree[key], built.unifiedTabsByWorktree[key]) + ) + keyed('groupsByWorktree', (key) => + mergeById(state.groupsByWorktree[key], built.groupsByWorktree[key]) + ) + keyed('browserTabsByWorktree', (key) => + mergeById( + state.browserTabsByWorktree[key], + built.browserTabsByWorktree[key], + (existing, next) => { + if (!existing) { + return next + } + const layoutFields = (tab: typeof next) => + BROWSER_TAB_LAYOUT_FIELDS.map((field) => tab[field]) + // Page state is this window's; main holds only the tab's own fields. + return same(layoutFields(existing), layoutFields(next)) + ? existing + : { + ...existing, + ...Object.fromEntries(BROWSER_TAB_LAYOUT_FIELDS.map((f) => [f, next[f]])) + } + } + ) + ) + for (const slice of [ + 'layoutByWorktree', + 'activeGroupIdByWorktree', + 'activeTabIdByWorktree', + 'activeFileIdByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree' + ] as const) { + keyed(slice, (key) => built[slice][key]) + } + + const tabIdsOf = (tabs: Record): Set => + new Set(keys.flatMap((key) => (tabs[key] ?? []).map((tab) => tab.id))) + let terminalLayouts = state.terminalLayoutsByTabId + for (const tabId of new Set([ + ...tabIdsOf(state.tabsByWorktree), + ...tabIdsOf(built.tabsByWorktree) + ])) { + terminalLayouts = + setKey(terminalLayouts, tabId, built.terminalLayoutsByTabId[tabId]) ?? terminalLayouts + } + if (terminalLayouts !== state.terminalLayoutsByTabId) { + patch.terminalLayoutsByTabId = terminalLayouts + } + + const keySet = new Set(keys) + // An open file's editor state is this window's; a frame decides only which files are open. + const builtFileIds = new Set(built.openFiles.map((file) => file.id)) + const heldFileIds = new Set(state.openFiles.map((file) => file.id)) + const files = [ + ...state.openFiles.filter((file) => !keySet.has(file.worktreeId) || builtFileIds.has(file.id)), + ...built.openFiles.filter((file) => !heldFileIds.has(file.id)) + ] + if ( + files.length !== state.openFiles.length || + files.some((file, index) => file !== state.openFiles[index]) + ) { + patch.openFiles = files + } + + // Pages and handles are this window's: a frame brings those of a tab it has not seen and drops + // those of a tab main closed. + const builtBrowserTabIds = new Set( + keys.flatMap((key) => (built.browserTabsByWorktree[key] ?? []).map((tab) => tab.id)) + ) + const closedBrowserTabIds = keys + .flatMap((key) => state.browserTabsByWorktree[key] ?? []) + .map((tab) => tab.id) + .filter((id) => !builtBrowserTabIds.has(id)) + const closedPageIds = new Set( + closedBrowserTabIds.flatMap((id) => + (state.browserPagesByWorkspace[id] ?? []).map((page) => page.id) + ) + ) + const pages = { ...state.browserPagesByWorkspace } + for (const id of closedBrowserTabIds) { + delete pages[id] + } + const handles = Object.fromEntries( + Object.entries(state.remoteBrowserPageHandlesByPageId).filter(([id]) => !closedPageIds.has(id)) + ) + const addedPages = Object.entries(built.browserPagesByWorkspace).filter(([id]) => !(id in pages)) + const addedHandles = Object.entries(built.remoteBrowserPageHandlesByPageId).filter( + ([id]) => !(id in handles) + ) + if (closedBrowserTabIds.length > 0 || addedPages.length > 0) { + patch.browserPagesByWorkspace = { ...pages, ...Object.fromEntries(addedPages) } + } + if (closedPageIds.size > 0 || addedHandles.length > 0) { + patch.remoteBrowserPageHandlesByPageId = { ...handles, ...Object.fromEntries(addedHandles) } + } + return patch +} diff --git a/src/shared/workspace-layout/workspace-layout-beside.ts b/src/shared/workspace-layout/workspace-layout-beside.ts index 5e2ccf923ba..2cd81e2e913 100644 --- a/src/shared/workspace-layout/workspace-layout-beside.ts +++ b/src/shared/workspace-layout/workspace-layout-beside.ts @@ -60,9 +60,9 @@ export type ViewSessionField = FieldsOwnedBy<'view'> export type FactSessionField = FieldsOwnedBy<'fact'> export type CarriedSessionField = FieldsOwnedBy<'carried'> -export const VIEW_SESSION_FIELDS = sessionFieldsOwnedBy('view') -export const FACT_SESSION_FIELDS = sessionFieldsOwnedBy('fact') -export const CARRIED_SESSION_FIELDS = sessionFieldsOwnedBy('carried') +export const VIEW_SESSION_FIELDS = /* @__PURE__ */ sessionFieldsOwnedBy('view') +export const FACT_SESSION_FIELDS = /* @__PURE__ */ sessionFieldsOwnedBy('fact') +export const CARRIED_SESSION_FIELDS = /* @__PURE__ */ sessionFieldsOwnedBy('carried') function sessionFieldsOwnedBy( owner: Owner