From bde20afb1d76e6ba801b955b267dc327e040d8bf Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:13:15 -0400 Subject: [PATCH] Only the tabs the window just changed wait for main's answer; the window-wide layout hold goes (dark, layout-owner build only) (#30384) * chore(layout): the drift check's ignore comment names every main-only kind (dark, layout-owner build only) * fix(layout): a quit stage retried after a failed one still sends the resume records (dark, layout-owner build only) * feat(layout): the window's local layout stream reopens after a loss, and its snapshot redraws every workspace (dark, layout-owner build only) * fix(layout): a command whose target main's copy lacks is a logged refusal, not the window's own change (dark, layout-owner build only) * feat(layout): frames keep each tab the window drew, closed or moved as drawn until main answers; the global frame hold goes (dark, layout-owner build only) A refused draw-first terminal create is closed (its terminal stopped) at the refusal. The activation create no longer draws main's tab itself while the stream is down: the reopened stream's snapshot does. * fix(layout): a tab move or drag-out the frame already drew is not placed again by the window (dark, layout-owner build only) Also holds the group sizes the window drew until main saves them. * test(layout): with frames flowing, a pin promotes a preview tab once and tab-change bookkeeping runs once (dark, layout-owner build only) Also states that the held-tab overlay builds frame slices only. * test(layout): read the rename's title without a cast * chore(layout): the drift check's header lists the same ignored records as its inline comment --- config/renderer-layout-writer-baseline.txt | 1 + .../persistence/window-save-divergence.ts | 5 +- .../tab-group-layout-owner-commands.test.ts | 16 + .../tab-group-layout-owner-commands.ts | 15 +- .../terminal-pane-layout-owner-commands.ts | 3 +- ...erminal-pane-layout-owner-gestures.test.ts | 22 +- .../terminal-pane-layout-owner-gestures.ts | 1 - ...inal-pane-split-with-inherited-cwd.test.ts | 22 +- .../terminal-pane-split-with-inherited-cwd.ts | 1 - ...rminal-pane-tab-detach-frame-drawn.test.ts | 25 + .../terminal-pane/terminal-pane-tab-detach.ts | 19 +- .../use-terminal-watcher-effects.ts | 7 +- .../session-write-subscriber-activity.test.ts | 71 --- .../src/lib/session-write-subscriber.ts | 30 -- .../src/lib/window-session-write-activity.ts | 30 -- ...live-surface-adoption-layout-owner.test.ts | 12 +- .../src/runtime/layout-apply-send.test.ts | 59 ++- src/renderer/src/runtime/layout-apply-send.ts | 108 +++-- .../src/runtime/layout-owner-command.test.ts | 24 +- .../runtime/layout-owner-drawn-tab-holds.ts | 78 ++++ .../workspace-layout-frame-held-tabs.test.ts | 440 ++++++++++++++++++ .../workspace-layout-frame-sync.test.ts | 56 +-- .../runtime/workspace-layout-frame-sync.ts | 47 +- .../workspace-layout-frame-tab-props.test.ts | 7 +- .../editor-layout-owner-records.test.ts | 2 + ...-terminal-tab-changes-layout-owner.test.ts | 2 +- .../terminal-tab-close-layout-owner.ts | 7 +- ...al-tab-create-through-layout-owner.test.ts | 53 ++- ...erminal-tab-create-through-layout-owner.ts | 61 +-- .../store/workspace-layout-frame-held-tabs.ts | 201 ++++++++ .../workspace-layout-frame-store-patch.ts | 12 +- 31 files changed, 1058 insertions(+), 379 deletions(-) delete mode 100644 src/renderer/src/lib/session-write-subscriber-activity.test.ts delete mode 100644 src/renderer/src/lib/window-session-write-activity.ts create mode 100644 src/renderer/src/runtime/layout-owner-drawn-tab-holds.ts create mode 100644 src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts create mode 100644 src/renderer/src/store/workspace-layout-frame-held-tabs.ts diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index cbe93df298a..1a780e4baa6 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -117,5 +117,6 @@ 5 src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts 2 src/renderer/src/store/terminals/workspace-terminal-hydration.ts 2 src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +7 src/renderer/src/store/workspace-layout-frame-held-tabs.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/main/persistence/window-save-divergence.ts b/src/main/persistence/window-save-divergence.ts index f6712503d44..b16c0a9880b 100644 --- a/src/main/persistence/window-save-divergence.ts +++ b/src/main/persistence/window-save-divergence.ts @@ -2,8 +2,9 @@ // its own layout over a window save, so a save whose layout differs from main's model shows a // change main never got as a command. Report-only, and only when its log is set (evidence runs); // it never alters, fails or delays the write. Records main keeps through its own writes (pane -// bindings, default-tab marks, closed-tab records, terminal row owners) are not compared: a save -// that has not caught up with them asks main for nothing. Deleted with the old path (phase F). +// bindings, resume records, default-tab marks, closed-tab records, terminal row owners, +// client-hosted browser pages, the topology revision) are not compared: a save that has not +// caught up with them asks main for nothing. Deleted with the old path (phase F). import { appendFileSync } from 'node:fs' import { diff --git a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts index 198b8e3e959..68472f46776 100644 --- a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts +++ b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { beginLayoutFramePositions, endLayoutFramePositions } from '@/runtime/layout-frame-position' import { createTestStore, makeWorktree, seedStore } from '@/store/slices/store-test-helpers' import { dropTabThroughLayoutOwner, @@ -139,6 +140,21 @@ describe('dropTabThroughLayoutOwner', () => { expect(store().getState().activeGroupIdByWorktree[WT]).toBe(right) }) + it('leaves placing a lone tab’s group to main’s frame while frames flow', () => { + const { left, right } = seeded() + const layout = store().getState().layoutByWorktree[WT] + beginLayoutFramePositions() + try { + dropTabThroughLayoutOwner(WT, { kind: 'split', tabId: 'c', groupId: left, direction: 'left' }) + lastHandlers().apply({ groupId: right }) + } finally { + endLayoutFramePositions() + } + + expect(store().getState().layoutByWorktree[WT]).toBe(layout) + expect(store().getState().activeGroupIdByWorktree[WT]).toBe(right) + }) + it('leaves a drop the window would skip to today’s path, sending nothing', () => { const { left, right } = seeded() diff --git a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts index 0681061bc37..9007f61bd08 100644 --- a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts +++ b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts @@ -1,11 +1,12 @@ // A layout-owner build's tab moves and group sizes. A move: main places the tab first, then the -// window shows main's answer. Main's frame usually shows the move before main answers; while this -// window owes main a save the frame waits, so the window then makes the same move itself, with -// main's new group id. Group sizes: drawn while dragging, main saves them when the drag ends. +// window shows main's answer. Main's frame shows the move before main answers; only when no frame +// is coming does the window make the same move itself, with main's new group id. Group sizes: +// drawn while dragging, main saves them when the drag ends. import { useAppStore } from '@/store' import type { TabSplitDirection } from '@/store/slices/tabs' import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' +import { layoutFramesFlowing } from '@/runtime/layout-frame-position' import type { WindowLayoutCommand } from '@/runtime/layout-apply-send' import { moveGroupLeafBeside } from '../../../../shared/workspace-layout/tab-group-layout-tree' import { stableJson } from '../../../../shared/workspace-layout/workspace-layout-load-report' @@ -134,7 +135,10 @@ function showMainMove(worktreeId: string, drop: TabDrop, mainGroupId: string | u moveAsToday(worktreeId, drop) return } - placeGroupBeside(worktreeId, groupId, drop.groupId, drop.direction) + // Main's frame placed the group; the window places it only when no frame is coming. + if (!layoutFramesFlowing()) { + placeGroupBeside(worktreeId, groupId, drop.groupId, drop.direction) + } } state.activateTab(tabId, { worktreeId, preservePreview: true, recordFocus: false }) useAppStore.getState().focusGroup(worktreeId, groupId) @@ -170,12 +174,11 @@ export function setGroupRatioThroughLayoutOwner( state.setTabGroupSplitRatio(worktreeId, nodePath, ratio) const groupLayout = useAppStore.getState().layoutByWorktree[worktreeId] if (groupLayout && groupLayout !== state.layoutByWorktree[worktreeId]) { - // Why keep on every answer: the window's own save still writes these sizes in this phase. const keep = (): void => {} sendLayoutCommandToOwner( worktreeId, { type: 'setGroupRatios', groupLayout }, - { apply: keep, asToday: keep } + { apply: keep, asToday: keep, drawn: true } ) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts index 33c063cdc4a..698a3172de2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts @@ -31,8 +31,7 @@ export function equalizeTerminalPanes( then(manager) } }, - asToday: equalize, - unapplied: () => {} + asToday: equalize } ) ) { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.test.ts index 50a456e6273..bd37d611776 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.test.ts @@ -55,7 +55,6 @@ describe('layoutOwnerPaneGestures', () => { { apply: expect.any(Function), asToday: expect.any(Function), - unapplied: expect.any(Function), refused: expect.any(Function) } ) @@ -74,22 +73,13 @@ describe('layoutOwnerPaneGestures', () => { expect(move).toHaveBeenCalledOnce() }) - it.each(['refused', 'unapplied'] as const)( - 'keeps the window’s shape when main answers %s, as main’s tree did not change', - (outcome) => { - const move = vi.fn() - gestures().takePaneDrop?.({ sourcePaneId: 2, targetPaneId: 1, zone: 'left' }, move) - const handlers = mocks.send.mock.calls[0]?.[2] + it('keeps the window’s shape when main refuses, as main’s tree did not change', () => { + const move = vi.fn() + gestures().takePaneDrop?.({ sourcePaneId: 2, targetPaneId: 1, zone: 'left' }, move) + mocks.send.mock.calls[0]?.[2]?.refused?.('same_pane') - if (outcome === 'refused') { - handlers?.refused?.('same_pane') - } else { - handlers?.unapplied?.() - } - - expect(move).not.toHaveBeenCalled() - } - ) + expect(move).not.toHaveBeenCalled() + }) it('holds divider ratios until every move in flight has settled, even one that threw', () => { const view = gestures() diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.ts index 6fb0eb9aa94..0462e87b2b5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-gestures.ts @@ -63,7 +63,6 @@ export function layoutOwnerPaneGestures(pane: { apply: () => settle(true), asToday: () => settle(true), // Main's tree did not change, and frames draw main's tree: the window keeps its shape. - unapplied: () => settle(false), refused: () => settle(false) } ) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts index 0e3cefb27a8..33d636951bb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts @@ -308,22 +308,14 @@ describe('splitTerminalPaneWithInheritedCwd', () => { expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull() }) - it.each(['unapplied', 'refused'] as const)( - 'in a layout-owner build, forgets the pane when main answers %s', - (outcome) => { - splitInLayoutOwnerBuild(makeManager(vi.fn())) - const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? [] + it('in a layout-owner build, forgets the pane when main refuses', () => { + splitInLayoutOwnerBuild(makeManager(vi.fn())) + const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? [] + handlers?.refused?.('pane_not_found') - if (outcome === 'refused') { - handlers?.refused?.('pane_not_found') - } else { - handlers?.unapplied?.() - } - - const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : '' - expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull() - } - ) + const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : '' + expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull() + }) it('in a layout-owner build, splits here when main answers as today', () => { const splitPane = vi.fn(() => ({ id: 2 })) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts index 358b7dbc7e4..c08ac528031 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts @@ -65,7 +65,6 @@ export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void { forget() splitLocally(args, () => cwd) }, - unapplied: forget, refused: forget } ) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-frame-drawn.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-frame-drawn.test.ts index 05eaf5c7ad4..16e27581837 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-frame-drawn.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-frame-drawn.test.ts @@ -195,6 +195,31 @@ describe('a drag-out whose new tab main’s frame drew first', () => { expect(state.activeTabIdByWorktree[WT]).toBe(MOVED) }) + it('leaves the tab where main’s frame placed it, asked for an index', async () => { + const store = storeWithFrameDrawnMove() + mocks.send.mockImplementation((_worktreeId, _command, handlers) => { + handlers.apply({ tabId: MOVED, ptyId: 'pty-right' }) + return true + }) + + await detachTerminalPaneToTab({ + getStore: () => store.getState(), + manager: { + getPanes: () => [{ id: 1 }, { id: 2 }], + getLeafId: (paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1), + detachPaneForExternalMove: () => true + }, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE, + targetGroupId: GROUP, + targetIndex: 0, + worktreeId: WT + }) + + expect(store.getState().groupsByWorktree[WT]![0]!.tabOrder).toEqual([SOURCE, MOVED]) + }) + it('opens a dragged-out chat pane’s tab in chat view, as a window-owned build does', async () => { const store = storeWithChatSplit() mocks.send.mockImplementation((_worktreeId, _command, handlers) => { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index ba5a3a58ecc..7e651cb9577 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -235,14 +235,17 @@ function applyMove( recordInteraction: true }) const afterCreateStore = args.getStore() - moveCreatedTabToIndex({ - groupId: args.targetGroupId, - store: afterCreateStore, - tabId: tab.id, - targetIndex: args.targetIndex, - worktreeId, - mainPlaced: mainMoved - }) + // Main's frame drew the tab where main placed it; the window places it only when it drew it. + if (!frameDrawn) { + moveCreatedTabToIndex({ + groupId: args.targetGroupId, + store: afterCreateStore, + tabId: tab.id, + targetIndex: args.targetIndex, + worktreeId, + mainPlaced: mainMoved + }) + } if (split) { afterCreateStore.setTabLayout(sourceTabId, split.sourceLayout) } diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 8b94f86c89a..d645860eac3 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -252,11 +252,8 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) // Switch point: main decides whether the workspace gets its first tab. if ( ORCA_LAYOUT_OWNER_IS_MAIN && - createInitialTerminalThroughLayoutOwner(useAppStore.getState, activeWorktreeId, (ids) => - createTab(activeWorktreeId, undefined, undefined, { - pendingActivationSpawn: true, - ...(ids ? { id: ids.tabId, initialLeafId: ids.leafId } : {}) - }) + createInitialTerminalThroughLayoutOwner(useAppStore.getState, activeWorktreeId, () => + createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) ) ) { return diff --git a/src/renderer/src/lib/session-write-subscriber-activity.test.ts b/src/renderer/src/lib/session-write-subscriber-activity.test.ts deleted file mode 100644 index 0963071488a..00000000000 --- a/src/renderer/src/lib/session-write-subscriber-activity.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 cd6ebceccb3..73e837ea9dd 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -4,8 +4,6 @@ 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'] @@ -126,21 +124,9 @@ 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 @@ -161,9 +147,6 @@ 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 @@ -181,10 +164,6 @@ 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 }) @@ -209,9 +188,6 @@ export function createSessionWriteSubscriber({ clearTimeout(timer) } timer = setTimeout(flushPendingWrite, debounceMs) - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - reportWindowSessionWriteActivity(owesWrite()) - } } /** @@ -242,9 +218,6 @@ 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 @@ -307,8 +280,5 @@ 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 deleted file mode 100644 index 519d19e4388..00000000000 --- a/src/renderer/src/lib/window-session-write-activity.ts +++ /dev/null @@ -1,30 +0,0 @@ -// 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/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts index da86bf996c2..a390ea68cb0 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts @@ -14,7 +14,6 @@ import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test- import { provideLayoutFrameTerminalPanes } from '../runtime/layout-frame-terminal-panes' import { openWorkspaceLayoutFrameSync } from '../runtime/workspace-layout-frame-sync' import type { WorkspaceLayoutFrameSync } from '../runtime/workspace-layout-frame-sync' -import { reportWindowSessionWriteActivity } from './window-session-write-activity' import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' import { createTestStore } from '../store/slices/store-test-helpers' import { adoptLiveWorkspacePtySurfaces } from './worktree-agent-live-surface-adoption' @@ -93,7 +92,6 @@ describe('adopting live terminals in a layout-owner build', () => { let sync: WorkspaceLayoutFrameSync | null = null beforeEach(() => { - reportWindowSessionWriteActivity(false) sync?.close() sync = null provideLayoutFrameTerminalPanes({ @@ -102,15 +100,13 @@ describe('adopting live terminals in a layout-owner build', () => { }) }) - it('makes no tab or pane of its own while frames are held; the frame then draws the bound tab', async () => { + it('makes no tab or pane of its own before frames draw; the frame then draws the bound tab', async () => { const { window: windowSession, main } = sessions() const store = createTestStore() await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }]) sync = openWorkspaceLayoutFrameSync(store) provideLayoutFrameTerminalPanes(sync) - sync.start() - // The window owes main a save, so main's tree waits. - reportWindowSessionWriteActivity(true) + // Main's snapshot is in, but the window has not started drawing from frames yet. frames.onFrame!(snapshotOf(main)) await flushMicrotasks() const createTab = vi.spyOn(store.getState(), 'createTab') @@ -128,7 +124,7 @@ describe('adopting live terminals in a layout-owner build', () => { expect(setTabLayout).not.toHaveBeenCalled() expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toEqual(['tab-other']) - reportWindowSessionWriteActivity(false) + sync.start() await flushMicrotasks() const layout = store.getState().terminalLayoutsByTabId['tab-split'] expect(layout?.root).toMatchObject({ @@ -157,8 +153,6 @@ describe('adopting live terminals in a layout-owner build', () => { await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }]) sync = openWorkspaceLayoutFrameSync(store) provideLayoutFrameTerminalPanes(sync) - sync.start() - reportWindowSessionWriteActivity(true) frames.onFrame!(snapshotOf(main)) await flushMicrotasks() diff --git a/src/renderer/src/runtime/layout-apply-send.test.ts b/src/renderer/src/runtime/layout-apply-send.test.ts index 9a7400a54f0..24db2d01153 100644 --- a/src/renderer/src/runtime/layout-apply-send.test.ts +++ b/src/renderer/src/runtime/layout-apply-send.test.ts @@ -24,13 +24,17 @@ vi.mock('@/runtime/layout-frame-position', () => ({ })) vi.mock('@/runtime/layout-owner-pane-intents', () => ({ holdTabPaneTree: () => () => {} })) -import { sendLayoutApply } from './layout-apply-send' +import { recordLayoutApply, sendLayoutApply } from './layout-apply-send' +import { isDrawnGroupLayoutHeld, isDrawnTabHeld } from './layout-owner-drawn-tab-holds' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner lookup is mocked and never reads the state. +const NO_STATE = {} as never const RENAME = { type: 'renamePane', tabId: 'tab-1', leafId: 'leaf-1', title: 'Pane' } as const async function send(reply: LayoutApplyReply | Error) { mocks.sent.reply = reply - const handlers = { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn(), unapplied: vi.fn() } + const handlers = { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner lookup is mocked and never reads the state. sendLayoutApply({} as never, 'wt-1', RENAME, handlers) await vi.waitFor(() => { @@ -53,13 +57,17 @@ describe('sendLayoutApply', () => { it.each([ { reply: { ok: false, code: 'other_partition' } as const, code: 'other_partition' }, { reply: { ok: false, code: 'host_not_owned' } as const, code: 'host_not_owned' }, - { reply: new Error('disk refused'), code: 'call_failed' } + { reply: new Error('disk refused'), code: 'call_failed' }, + // Main's copy lacking the target is a refusal too: a frame drew what the window shows. + { reply: { ok: false, code: 'tab_not_found' } as const, code: 'tab_not_found' }, + { reply: { ok: false, code: 'pane_not_found' } as const, code: 'pane_not_found' }, + { reply: { ok: false, code: 'workspace_not_found' } as const, code: 'workspace_not_found' }, + { reply: { ok: false, code: 'group_not_found' } as const, code: 'group_not_found' } ])('logs a $code refusal and draws nothing of its own', async ({ reply, code }) => { const handlers = await send(reply) expect(handlers.refused).toHaveBeenCalledWith(code) expect(handlers.asToday).not.toHaveBeenCalled() - expect(handlers.unapplied).not.toHaveBeenCalled() expect(console.warn).toHaveBeenCalledWith( expect.stringContaining(`[layout-owner] renamePane refused`), ...(reply instanceof Error ? ['disk refused'] : [code]) @@ -92,4 +100,47 @@ describe('sendLayoutApply', () => { const applied = await send({ ok: true, result: { tabId: 'tab-1' } }) expect(applied.apply).toHaveBeenCalledWith({ tabId: 'tab-1' }) }) + + it('holds the tabs a drawn change names, in each workspace, until its answer is in', async () => { + mocks.sent.reply = { ok: true, result: {} } + recordLayoutApply(NO_STATE, 'wt-1', { + type: 'moveTabToWorkspace', + toWorkspace: 'wt-2', + tabs: [{ tabId: 'tab-1', newTabId: 'tab-1b', newEntityId: 'file-1b' }], + groupId: 'group-2' + }) + expect(isDrawnTabHeld('wt-1', 'tab-1')).toBe(true) + expect(isDrawnTabHeld('wt-2', 'tab-1b')).toBe(true) + expect(isDrawnTabHeld('wt-2', 'file-1b')).toBe(true) + expect(isDrawnTabHeld('wt-2', 'tab-1')).toBe(false) + await vi.waitFor(() => expect(isDrawnTabHeld('wt-1', 'tab-1')).toBe(false)) + expect(isDrawnTabHeld('wt-2', 'tab-1b')).toBe(false) + }) + + it('holds group sizes the window set, and nothing for a change main draws first', async () => { + mocks.sent.reply = { ok: true, result: {} } + const keep = vi.fn() + sendLayoutApply( + NO_STATE, + 'wt-1', + { type: 'setGroupRatios', groupLayout: { type: 'leaf', groupId: 'g' } }, + { + apply: keep, + asToday: keep, + drawn: true + } + ) + sendLayoutApply( + NO_STATE, + 'wt-1', + { type: 'moveTab', tabId: 'tab-9', toGroupId: 'g', index: 0 }, + { + apply: keep, + asToday: keep + } + ) + expect(isDrawnGroupLayoutHeld('wt-1')).toBe(true) + expect(isDrawnTabHeld('wt-1', 'tab-9')).toBe(false) + await vi.waitFor(() => expect(isDrawnGroupLayoutHeld('wt-1')).toBe(false)) + }) }) diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index 7ece639f7b8..3f6bf6f7877 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -6,6 +6,7 @@ import { import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { afterLayoutFrame } from '@/runtime/layout-frame-position' import { holdTabPaneTree } from '@/runtime/layout-owner-pane-intents' +import { holdDrawnTabs, type DrawnTabHold } from '@/runtime/layout-owner-drawn-tab-holds' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import type { TabGroupLayoutNode } from '../../../shared/tab-types' import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' @@ -13,6 +14,7 @@ import type { PaneSide } from '../../../shared/workspace-layout/terminal-pane-tr import type { LayoutEditorFile } from '../../../shared/workspace-layout/workspace-layout-model' import type { EditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type' import type { + CommandOf, EditorFileRename, GroupSide, LayoutApplyReply, @@ -132,6 +134,7 @@ export type WindowLayoutCommand = /** Drops the panes' resume records without starting them. */ | { type: 'wake'; tabId: string; paneKeys: string[]; recordOnly: true } | { type: 'setDefaultTabsApplied' } + | (Omit, 'workspace'> & { tabId: string; leafId: string }) /** `call_failed`: the call threw, and a thrown write is rolled back. */ type WindowRefusalCode = Extract['code'] | 'call_failed' @@ -140,10 +143,8 @@ export type LayoutApplyHandlers = { apply: (result: LayoutCommandResult) => void asToday: () => void refused?: (code: WindowRefusalCode) => void - /** Main's copy lacks the target; `asToday` when absent. A pane split or move - * passes one: main keeps its pane trees over the window's saves, so that change would not last. - * A close keeps `asToday`: a pane main lacks has nothing to keep it. */ - unapplied?: () => void + /** The window already drew the change: frames keep its tabs as drawn until the answer is in. */ + drawn?: true } /** Commands that change one tab's panes: frames wait to redraw that tab until the answer is in. */ @@ -158,13 +159,70 @@ const PANE_COMMANDS: ReadonlySet = new Set([ 'movePaneToNewTab' ]) +/** The tabs, by workspace, that a command the window already drew changes. */ +function drawnTabHolds(worktreeId: string, command: WindowLayoutCommand): DrawnTabHold[] { + switch (command.type) { + case 'closeTabs': + return [{ workspace: worktreeId, ids: command.tabIds }] + case 'openBrowserTab': + return [{ workspace: worktreeId, ids: [command.tabId, command.browserTabId] }] + case 'openEditorTab': + return [{ workspace: worktreeId, ids: [command.tabId, command.fileId] }] + case 'splitGroup': + return [{ workspace: worktreeId, ids: [command.tabId], groupLayout: true }] + case 'moveTab': + case 'renameTab': + case 'setTabProps': + case 'promotePreviewTab': + case 'openAgentSessionTab': + case 'openSimulatorTab': + case 'createTerminalTab': + return [{ workspace: worktreeId, ids: [command.tabId] }] + case 'openBrowserPage': + case 'closeBrowserPage': + case 'replaceBrowserPage': + case 'setBrowserTabProfile': + return [{ workspace: worktreeId, ids: [command.browserTabId] }] + case 'renameEditorFiles': + return [ + { + workspace: worktreeId, + ids: command.renames.flatMap((rename) => [rename.fileId, rename.newFileId]) + } + ] + case 'moveTabToWorkspace': + return [ + { workspace: worktreeId, ids: command.tabs.map((tab) => tab.tabId) }, + { + workspace: command.toWorkspace, + ids: command.tabs.flatMap((tab) => [tab.newTabId, tab.newEntityId]) + } + ] + case 'setGroupRatios': + return [{ workspace: worktreeId, ids: [], groupLayout: true }] + // Pane commands hold their tab's pane tree; records are not drawn from frames. + case 'splitPane': + case 'closePane': + case 'renamePane': + case 'equalizePanes': + case 'movePane': + case 'setPaneRatios': + case 'setChatPane': + case 'movePaneToNewTab': + case 'sleep': + case 'wake': + case 'setDefaultTabsApplied': + return [] + } +} + /** * A layout command in a layout-owner build: main applies it first, then, once the window has the * frame with main's change, `apply` shows main's result. `asToday` runs instead only when a paired - * server may hold the tab (#18202), or main's copy lacks the workspace, tab, pane or group. Any - * other refusal, and a call that threw, is logged and `refused`: main's frame stays what is drawn, - * since a window save no longer carries the change. False when a paired server owns the - * workspace's layout; the caller acts as today. + * server may hold the tab (#18202). Any other refusal (main's copy lacking the target too), and a + * call that threw, is logged and `refused`: main's frame stays what is drawn, since a window save + * no longer carries the change. False when a paired server owns the workspace's layout; the + * caller acts as today. */ export function sendLayoutApply( state: Parameters[0], @@ -180,12 +238,17 @@ export function sendLayoutApply( } const hold = PANE_COMMANDS.has(command.type) && 'tabId' in command ? holdTabPaneTree(command.tabId) : null + const tabHolds = handlers.drawn ? drawnTabHolds(worktreeId, command) : [] + const tabHold = tabHolds.length > 0 ? holdDrawnTabs(tabHolds) : null // Why a task later: a handler that resolves a promise (drag-out) acts in its awaiting caller, // microtasks after the handler returns. const release = (): void => { if (hold) { setTimeout(hold, 0) } + if (tabHold) { + setTimeout(tabHold, 0) + } } void callRuntimeRpc({ kind: 'local' }, 'layout.apply', { command: { ...command, workspace: worktreeId } @@ -228,10 +291,6 @@ function handleReply( handlers.apply(reply.result) } else if (reply.code === 'host_not_owned' && mayBePairedServers(state, worktreeId)) { handlers.asToday() - } else if (mainLacksTarget(reply.code)) { - // Why: main's copy can lag the window's: a tab the window drew before main's create answered. - console.warn(`[layout-owner] ${command.type} found no target:`, reply.code) - unapplied(handlers) } else { console.warn(`[layout-owner] ${command.type} refused:`, reply.code) handlers.refused?.(reply.code) @@ -248,32 +307,19 @@ function mayBePairedServers( ) } -function unapplied(handlers: LayoutApplyHandlers): void { - if (handlers.unapplied) { - handlers.unapplied() - } else { - handlers.asToday() - } -} - const keepWindowCopy = (): void => {} -/** Records a change the window already made; any answer leaves the window's copy as it is. */ +/** Records a change the window already made: drawn as the window made it until main answers. */ export function recordLayoutApply( state: Parameters[0], worktreeId: string, command: WindowLayoutCommand ): void { - sendLayoutApply(state, worktreeId, command, { apply: keepWindowCopy, asToday: keepWindowCopy }) -} - -function mainLacksTarget(code: WindowRefusalCode): boolean { - return ( - code === 'workspace_not_found' || - code === 'tab_not_found' || - code === 'pane_not_found' || - code === 'group_not_found' - ) + sendLayoutApply(state, worktreeId, command, { + apply: keepWindowCopy, + asToday: keepWindowCopy, + drawn: true + }) } function errorMessage(error: unknown): unknown { diff --git a/src/renderer/src/runtime/layout-owner-command.test.ts b/src/renderer/src/runtime/layout-owner-command.test.ts index 0d3191eed39..2471f20f7f9 100644 --- a/src/renderer/src/runtime/layout-owner-command.test.ts +++ b/src/renderer/src/runtime/layout-owner-command.test.ts @@ -105,36 +105,28 @@ describe('sendLayoutCommandToOwner', () => { 'main lacks the workspace', () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'workspace_not_found' }) ] - ])('acts as today when %s', async (_case, arrange) => { + ])('refuses, logging it and drawing nothing, when %s', async (_case, arrange) => { arrange() const on = handlers() sendLayoutCommandToOwner('worktree-1', SPLIT, on) - await vi.waitFor(() => expect(on.asToday).toHaveBeenCalledOnce()) - expect(on.refused).not.toHaveBeenCalled() + await vi.waitFor(() => expect(on.refused).toHaveBeenCalledOnce()) + expect(on.asToday).not.toHaveBeenCalled() + expect(console.warn).toHaveBeenCalledWith( + '[layout-owner] splitPane refused:', + expect.stringMatching(/_not_found$/) + ) }) it('refuses, drawing nothing, when the call fails', async () => { mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed')) - const on = { ...handlers(), unapplied: vi.fn() } + const on = handlers() sendLayoutCommandToOwner('worktree-1', SPLIT, on) await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('call_failed')) expect(on.asToday).not.toHaveBeenCalled() - expect(on.unapplied).not.toHaveBeenCalled() - }) - - it('draws nothing for a pane command passing `unapplied` when main lacks the pane', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'pane_not_found' }) - const on = { ...handlers(), unapplied: vi.fn() } - - sendLayoutCommandToOwner('worktree-1', SPLIT, on) - - await vi.waitFor(() => expect(on.unapplied).toHaveBeenCalledOnce()) - expect(on.asToday).not.toHaveBeenCalled() - await vi.waitFor(() => expect(isTabPaneTreeHeld('tab-1')).toBe(false)) }) it('keeps the tab’s drawn tree from frames until its answer, and what awaits it, has run', async () => { diff --git a/src/renderer/src/runtime/layout-owner-drawn-tab-holds.ts b/src/renderer/src/runtime/layout-owner-drawn-tab-holds.ts new file mode 100644 index 00000000000..a757b4b3fcf --- /dev/null +++ b/src/renderer/src/runtime/layout-owner-drawn-tab-holds.ts @@ -0,0 +1,78 @@ +// Tabs this window drew, closed or moved before main answered, in a layout-owner build: frames +// keep each as the window shows it until main's answer is in, then redraw its workspaces, so a +// frame sent before main had the change neither drops a just-drawn tab nor brings back a closed one. + +/** A workspace's held tabs, by tab-bar or entity id; `groupLayout`: the group sizes the window set. */ +export type DrawnTabHold = { workspace: string; ids: readonly string[]; groupLayout?: true } + +const held = new Map>() +const redrawListeners = new Set<(workspaces: readonly string[]) => void>() + +/** The tree key of a workspace's group sizes, never a tab id. */ +const GROUP_LAYOUT = '\0group-layout' + +function count(workspace: string, id: string, by: 1 | -1): void { + const ids = held.get(workspace) ?? new Map() + const next = (ids.get(id) ?? 0) + by + if (next > 0) { + ids.set(id, next) + } else { + ids.delete(id) + } + if (ids.size > 0) { + held.set(workspace, ids) + } else { + held.delete(workspace) + } +} + +function keysOf(hold: DrawnTabHold): string[] { + return hold.groupLayout ? [...hold.ids, GROUP_LAYOUT] : [...hold.ids] +} + +/** Keeps these as drawn until the release, which redraws their workspaces; a second release is a no-op. */ +export function holdDrawnTabs(holds: readonly DrawnTabHold[]): () => void { + for (const hold of holds) { + for (const id of keysOf(hold)) { + count(hold.workspace, id, 1) + } + } + let released = false + return () => { + if (released) { + return + } + released = true + for (const hold of holds) { + for (const id of keysOf(hold)) { + count(hold.workspace, id, -1) + } + } + const workspaces = [...new Set(holds.map((hold) => hold.workspace))] + for (const listener of redrawListeners) { + listener(workspaces) + } + } +} + +export function isDrawnTabHeld(workspace: string, id: string): boolean { + return held.get(workspace)?.has(id) ?? false +} + +export function isDrawnGroupLayoutHeld(workspace: string): boolean { + return isDrawnTabHeld(workspace, GROUP_LAYOUT) +} + +export function holdsDrawnTabs(workspace: string): boolean { + return held.has(workspace) +} + +export function onDrawnTabsRedraw(listener: (workspaces: readonly string[]) => void): () => void { + redrawListeners.add(listener) + return () => redrawListeners.delete(listener) +} + +export function _resetDrawnTabHoldsForTests(): void { + held.clear() + redrawListeners.clear() +} 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 new file mode 100644 index 00000000000..86f91881f1b --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts @@ -0,0 +1,440 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { Tab } from '../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-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 type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture' +import { buildWorkspaceSessionPayload } from '../lib/workspace-session' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' +import { recordOpenedEditorTabInLayoutOwner } from '../store/slices/editor/tabs/editor-tab-layout-owner' +import { recordOpenedAgentSessionTabInLayoutOwner } from './agent-session-tab-layout-owner' +import { recordOpenedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layout-owner' +import { withLayoutOwnerTabProps } from '@/components/tab-bar/tab-bar-layout-owner-props' +import { endLayoutFramePositions } from './layout-frame-position' +import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' + +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() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +type Call = { + command: { type: string; tabId?: string; title?: string } + answer: (r: LayoutApplyReply) => void + fail: (error: Error) => void +} +const effects = vi.hoisted(() => ({ + graphSync: vi.fn(), + mirrorViewMode: vi.fn(), + chatToggled: vi.fn() +})) +vi.mock('@/runtime/sync-runtime-graph', async (importOriginal) => ({ + ...(await importOriginal()), + scheduleRuntimeGraphSync: effects.graphSync +})) +vi.mock('@/store/slices/tabs/tabs-host-mirroring', async (importOriginal) => ({ + ...(await importOriginal()), + mirrorTabViewModeToHost: effects.mirrorViewMode +})) +vi.mock('@/lib/native-chat-telemetry', async (importOriginal) => ({ + ...(await importOriginal()), + emitNativeChatToggled: effects.chatToggled +})) + +const rpc = vi.hoisted((): { calls: Call[] } => ({ calls: [] })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (_target: unknown, _method: string, params: { command: { type: string } }) => + new Promise((answer, fail) => + rpc.calls.push({ command: params.command, answer, fail }) + ) +})) + +type StreamDouble = { + onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null + onPosition: ((seq: number) => void) | null +} +const stream = vi.hoisted((): StreamDouble => ({ onFrame: null, onPosition: null })) +vi.mock('./runtime-layout-client', () => ({ + subscribeRuntimeLayout: ( + _target: unknown, + _params: unknown, + onFrame: (frame: WorkspaceLayoutChangeFrame) => void, + _onStatus: unknown, + onPosition: (seq: number) => void + ) => { + stream.onFrame = onFrame + stream.onPosition = onPosition + return { close: () => {} } + } +})) + +createStoreSessionMockApi() + +function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout { + const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext()) + return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local') +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + +let seq = 0 +/** 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.onPosition!(seq) +} + +async function boot() { + const store = createTestStore() + const session = localDesktopSession() + 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 }) + stream.onPosition!(seq) + sync.start() + await settle() + /** Main's answer to the window's command, with the frame holding the window's change. */ + const answer = async ( + index: number, + reply: LayoutApplyReply = okReply, + /** Main's change the window has not drawn: a change main applies first. */ + mainChange: (layout: PublishedWorkspaceLayout) => void = () => {} + ): Promise => { + const frame = publishedOf(buildWorkspaceSessionPayload(store.getState())) + mainChange(frame) + seq += 1 + stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: frame, seq }) + stream.onPosition!(seq) + rpc.calls[index]!.answer({ ...reply, frameSeq: seq }) + await settle() + await settle() + } + return { store, sync, before, answer } +} + +const okReply: LayoutApplyReply = { ok: true, result: {} } + +const tabBar = (store: ReturnType) => + store.getState().unifiedTabsByWorktree[GIT_KEY]!.map((tab) => tab.id) +const groupOrder = (store: ReturnType, groupId: string) => + store.getState().groupsByWorktree[GIT_KEY]!.find((group) => group.id === groupId)?.tabOrder + +/** Draws a tab as the window's open does, before its recorder tells main. */ +function drawTab( + store: ReturnType, + tab: Partial & { id: string } +): Tab { + const drawn: Tab = { + entityId: tab.id, + groupId: 'group-b', + worktreeId: GIT_KEY, + contentType: 'simulator', + label: tab.id, + customLabel: null, + color: null, + sortOrder: 9, + createdAt: 1_700_000_900_000, + ...tab + } + const state = store.getState() + store.setState({ + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [GIT_KEY]: [...state.unifiedTabsByWorktree[GIT_KEY]!, drawn] + }, + groupsByWorktree: { + ...state.groupsByWorktree, + [GIT_KEY]: state.groupsByWorktree[GIT_KEY]!.map((group) => + group.id === drawn.groupId ? { ...group, tabOrder: [...group.tabOrder, drawn.id] } : group + ) + } + }) + return drawn +} + +beforeEach(() => { + rpc.calls = [] + vi.clearAllMocks() +}) +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 () => { + const { store, sync, before, answer } = await boot() + const tab = store.getState().createTab(GIT_KEY) + expect(rpc.calls[0]!.command).toMatchObject({ type: 'createTerminalTab', tabId: tab.id }) + + sendStaleFrame(before) + await settle() + expect(tabBar(store)).toContain(tab.id) + expect(store.getState().tabsByWorktree[GIT_KEY]!.map((row) => row.id)).toContain(tab.id) + expect(store.getState().terminalLayoutsByTabId[tab.id]).toBeDefined() + + await answer(0) + expect(tabBar(store)).toContain(tab.id) + sync.close() + }) + + it('keeps a just-closed terminal tab closed', async () => { + const { store, sync, before, answer } = await boot() + store.getState().closeTab('tab-unbound') + const close = rpc.calls.findIndex((call) => call.command.type === 'closeTabs') + expect(close).toBeGreaterThanOrEqual(0) + + sendStaleFrame(before) + await settle() + expect(tabBar(store)).not.toContain('tab-unbound') + expect(groupOrder(store, 'group-b')).not.toContain('tab-unbound') + expect(store.getState().tabsByWorktree[GIT_KEY]!.map((row) => row.id)).not.toContain( + 'tab-unbound' + ) + + await answer(close) + expect(tabBar(store)).not.toContain('tab-unbound') + sync.close() + }) + + it.each([ + [ + 'editor', + (store: ReturnType) => { + const file = { + ...store.getState().openFiles[0]!, + id: '/Users/dev/orca/src/new.ts', + filePath: '/Users/dev/orca/src/new.ts', + relativePath: 'src/new.ts' + } + store.setState({ openFiles: [...store.getState().openFiles, file] }) + const tab = drawTab(store, { + id: 'editor:new', + entityId: file.id, + contentType: 'editor' + }) + recordOpenedEditorTabInLayoutOwner(store.getState(), tab) + return tab + } + ], + [ + 'chat', + (store: ReturnType) => { + const tab = drawTab(store, { + id: 'agent-session-new', + contentType: 'agent-session', + agentSessionAgent: 'claude' + }) + recordOpenedAgentSessionTabInLayoutOwner(store.getState(), tab) + return tab + } + ], + [ + 'simulator', + (store: ReturnType) => { + const tab = drawTab(store, { id: 'simulator-new' }) + recordOpenedSimulatorTabInLayoutOwner(store.getState(), tab) + return tab + } + ] + ])('keeps a just-opened %s tab', async (_kind, open) => { + const { store, sync, before } = await boot() + const tab = open(store) + expect(rpc.calls.length).toBeGreaterThan(0) + + sendStaleFrame(before) + await settle() + expect(tabBar(store)).toContain(tab.id) + expect(groupOrder(store, 'group-b')).toContain(tab.id) + if (tab.contentType === 'editor') { + expect(store.getState().openFiles.map((file) => file.id)).toContain(tab.entityId) + } + sync.close() + }) + + it('keeps a just-opened browser tab and its page', async () => { + const { store, sync, before } = await boot() + const created = store.getState().createBrowserTab(GIT_KEY, 'https://example.com/', { + title: 'Example' + }) + const entry = store + .getState() + .unifiedTabsByWorktree[GIT_KEY]!.find((tab) => tab.entityId === created.id)! + expect(rpc.calls.some((call) => call.command.type === 'openBrowserTab')).toBe(true) + + sendStaleFrame(before) + await settle() + expect(tabBar(store)).toContain(entry.id) + expect(store.getState().browserTabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toContain( + created.id + ) + expect(store.getState().browserPagesByWorkspace[created.id]?.length).toBeGreaterThan(0) + sync.close() + }) + + it('keeps a reorder the window made, and main’s order for the rest', async () => { + const { store, sync, before, answer } = await boot() + const order = groupOrder(store, 'group-a')! + const reordered = order.toReversed() + store.getState().reorderUnifiedTabs('group-a', reordered) + const moves = rpc.calls.filter((call) => call.command.type === 'moveTab') + expect(moves.length).toBeGreaterThan(0) + + sendStaleFrame(before) + await settle() + expect(groupOrder(store, 'group-a')).toEqual(reordered) + + for (let index = 0; index < rpc.calls.length; index += 1) { + await answer(index) + } + expect(groupOrder(store, 'group-a')).toEqual(reordered) + sync.close() + }) + + it.each([ + ['refuses it', (call: Call) => call.answer({ ok: false, code: 'wsl_move_in_progress' })], + ['throws', (call: Call) => call.fail(new Error('layout_store_unavailable'))] + ])('removes a just-created terminal tab, stopping it, when main %s', async (_case, refuse) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { store, sync, before } = await boot() + const tab = store.getState().createTab(GIT_KEY) + const closeTab = vi.spyOn(store.getState(), 'closeTab') + sendStaleFrame(before) + await settle() + expect(tabBar(store)).toContain(tab.id) + + refuse(rpc.calls[0]!) + await settle() + await settle() + expect(closeTab).toHaveBeenCalledWith(tab.id, { reason: 'cleanup' }) + expect(tabBar(store)).not.toContain(tab.id) + expect(store.getState().tabsByWorktree[GIT_KEY]!.map((row) => row.id)).not.toContain(tab.id) + expect(store.getState().terminalLayoutsByTabId[tab.id]).toBeUndefined() + sync.close() + }) +}) + +describe('a change main applies first, with frames flowing', () => { + const callsOf = (type: string) => rpc.calls.filter((call) => call.command.type === type) + + it('pins a preview tab and promotes it once; a frame from before does not undo it', async () => { + const { store, sync, before, answer } = await boot() + const file = { + ...store.getState().openFiles[0]!, + id: '/Users/dev/orca/src/peek.ts', + filePath: '/Users/dev/orca/src/peek.ts', + relativePath: 'src/peek.ts', + isPreview: true + } + store.setState({ openFiles: [...store.getState().openFiles, file] }) + const tab = drawTab(store, { + id: 'editor:peek', + entityId: file.id, + contentType: 'editor', + isPreview: true + }) + recordOpenedEditorTabInLayoutOwner(store.getState(), tab) + await answer(0) + const entry = () => + store.getState().unifiedTabsByWorktree[GIT_KEY]!.find((item) => item.id === tab.id)! + + withLayoutOwnerTabProps( + { togglePinned: (item) => store.getState().pinFile(file.id, item.unifiedTabId) }, + () => GIT_KEY + ).togglePinned({ unifiedTabId: tab.id, isPinned: false }) + const pin = rpc.calls.length - 1 + expect(rpc.calls[pin]!.command).toMatchObject({ type: 'setTabProps', isPinned: true }) + await answer(pin, okReply, (layout) => { + layout.tabs.find((item) => item.id === tab.id)!.isPinned = true + }) + expect(callsOf('promotePreviewTab')).toHaveLength(1) + expect(entry()).toMatchObject({ isPinned: true }) + expect(entry().isPreview).toBeFalsy() + + sendStaleFrame(before) + await settle() + expect(entry().isPreview).toBeFalsy() + await answer(rpc.calls.indexOf(callsOf('promotePreviewTab')[0]!)) + expect(entry()).toMatchObject({ isPinned: true }) + expect(entry().isPreview).toBeFalsy() + expect(callsOf('promotePreviewTab')).toHaveLength(1) + sync.close() + }) + + it('runs a view toggle’s bookkeeping once, after main’s frame shows the toggle', async () => { + const { store, sync, answer } = await boot() + store.getState().toggleTabViewMode('tab-agent') + const toggle = rpc.calls.length - 1 + expect(rpc.calls[toggle]!.command).toMatchObject({ type: 'setTabProps', viewMode: 'terminal' }) + + await answer(toggle, okReply, (layout) => { + layout.tabs.find((item) => item.id === 'tab-agent')!.viewMode = 'terminal' + }) + const agent = () => + store.getState().unifiedTabsByWorktree[GIT_KEY]!.find((item) => item.id === 'tab-agent')! + expect(agent().viewMode).toBe('terminal') + expect(effects.mirrorViewMode).toHaveBeenCalledTimes(1) + expect(effects.chatToggled).toHaveBeenCalledTimes(1) + expect(effects.chatToggled).toHaveBeenCalledWith( + expect.objectContaining({ from: 'chat', to: 'terminal' }) + ) + + // The answered send is settled: the next toggle flips from what is shown, back to chat. + store.getState().toggleTabViewMode('tab-agent') + expect(rpc.calls.at(-1)!.command).toMatchObject({ type: 'setTabProps', viewMode: 'chat' }) + sync.close() + }) + + it('settles a generated title once main’s frame shows it, so the next one is sent', async () => { + const { store, sync, answer } = await boot() + store.setState({ settings: { ...store.getState().settings!, tabAutoGenerateTitle: true } }) + const leafId = store.getState().terminalLayoutsByTabId['tab-unbound']!.activeLeafId! + const paneKey = makePaneKey('tab-unbound', leafId) + store.getState().setGeneratedTabTitleFromAgentPrompt(paneKey, 'Refactor the auth middleware') + const renames = () => callsOf('renameTab') + expect(renames()).toHaveLength(1) + const title = renames()[0]!.command.title! + // Unanswered: a later prompt for the same tab waits for the first. + store.getState().setGeneratedTabTitleFromAgentPrompt(paneKey, 'Fix the flaky login test') + expect(renames()).toHaveLength(1) + + await answer(rpc.calls.indexOf(renames()[0]!), okReply, (layout) => { + layout.tabs.find((item) => item.id === 'tab-unbound')!.generatedTitle = title + }) + const row = store.getState().tabsByWorktree[GIT_KEY]!.find((item) => item.id === 'tab-unbound')! + expect(row.generatedTitle).toBe(title) + store.getState().setGeneratedTabTitleFromAgentPrompt(paneKey, 'Fix the flaky login test', { + replaceExistingGeneratedTitle: true + }) + expect(renames()).toHaveLength(2) + sync.close() + }) + + it('runs a rename’s graph sync once and shows main’s title', async () => { + const { store, sync, answer } = await boot() + store.getState().setTabCustomTitle('tab-unbound', 'Build') + const rename = rpc.calls.length - 1 + expect(effects.graphSync).not.toHaveBeenCalled() + + await answer(rename, okReply, (layout) => { + layout.tabs.find((item) => item.id === 'tab-unbound')!.customTitle = 'Build' + }) + const row = store.getState().tabsByWorktree[GIT_KEY]!.find((item) => item.id === 'tab-unbound')! + expect(row.customTitle).toBe('Build') + expect(effects.graphSync).toHaveBeenCalledTimes(1) + sync.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 d8b1f7f991e..d48e13f484c 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, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, 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' @@ -17,7 +17,6 @@ import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layou import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' import { buildOwnedEditorFileId } from '../store/slices/editor/file-ids/editor-file-ids' 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' @@ -125,10 +124,6 @@ const FRAME_DIFFERENCE_FIXTURES = new Set([ 'session from before the tab bar, with an open file' ]) -beforeEach(() => { - reportWindowSessionWriteActivity(false) -}) - afterEach(() => endLayoutFramePositions()) describe('the window drawn from layout frames', () => { @@ -225,17 +220,6 @@ describe('the window drawn from layout frames', () => { await flushMicrotasks() expect(seen).toEqual(['renamed']) - // A held frame still releases its reply: the window's own save carries what it changed. - reportWindowSessionWriteActivity(true) - const held = structuredClone(renamed) - held.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'held' - afterLayoutFrame(3, () => seen.push(shellTitle())) - stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: held, seq: 3 }) - stream.onPosition!(3) - await flushMicrotasks() - expect(seen).toEqual(['renamed', 'renamed']) - reportWindowSessionWriteActivity(false) - const released = vi.fn() afterLayoutFrame(9, released) stream.onStatus!({ state: 'lost' }) @@ -249,27 +233,9 @@ describe('the window drawn from layout frames', () => { sync.close() }) - it('holds a structure frame while the window owes main a write, then shows it', async () => { + 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()) 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 main’s pane tree at once even while a write is owed, except a tab the window’s own pane command holds', async () => { - const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) - sync.start() - reportWindowSessionWriteActivity(true) const shellRoot = () => store.getState().terminalLayoutsByTabId['tab-shell']!.root const withRatio = (ratio: number) => { const next = structuredClone(layouts[GIT_KEY]!) @@ -293,16 +259,14 @@ describe('the window drawn from layout frames', () => { release() await flushMicrotasks() expect(shellRoot()).toMatchObject({ ratio: 0.7 }) - reportWindowSessionWriteActivity(false) sync.close() }) - it('shows main’s pane tree going back to one the window last showed before an owed write', async () => { + it('shows main’s pane tree going back to one the window showed before', async () => { const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) sync.start() const shellRoot = () => store.getState().terminalLayoutsByTabId['tab-shell']!.root const original = shellRoot() - reportWindowSessionWriteActivity(true) const changed = structuredClone(layouts[GIT_KEY]!) const root = changed.tabs.find((tab) => tab.id === 'tab-shell')!.terminal!.root! if (root.type === 'split') { @@ -312,11 +276,10 @@ describe('the window drawn from layout frames', () => { await flushMicrotasks() expect(shellRoot()).toMatchObject({ ratio: 0.15 }) - // Same structure as the frame shown before the write: its pane tree still shows. + // Same structure as the frame shown before: its pane tree still shows. stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: structuredClone(layouts[GIT_KEY]!) }) await flushMicrotasks() expect(shellRoot()).toEqual(original) - reportWindowSessionWriteActivity(false) sync.close() }) @@ -324,8 +287,7 @@ describe('the window drawn from layout frames', () => { const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession()) sync.start() await flushMicrotasks() - // A frame queued behind an owed write when the stream ends. - reportWindowSessionWriteActivity(true) + // A frame still queued when the stream ends. stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: structuredClone(layouts[GIT_KEY]!) }) stream.onStatus!({ state: 'lost' }) const drawn = { type: 'leaf' as const, leafId: 'drawn-by-the-window' } @@ -340,11 +302,6 @@ describe('the window drawn from layout frames', () => { await flushMicrotasks() expect(store.getState().terminalLayoutsByTabId['tab-shell']!.root).toEqual(drawn) - // Nor does a later flush, such as an owed write ending. - reportWindowSessionWriteActivity(true) - reportWindowSessionWriteActivity(false) - await flushMicrotasks() - expect(store.getState().terminalLayoutsByTabId['tab-shell']!.root).toEqual(drawn) sync.close() }) @@ -378,10 +335,9 @@ describe('the window drawn from layout frames', () => { sync.close() }) - it('shows a live title change of the focused pane at once, even while a write is owed', async () => { + it('shows a live title change of the focused pane without rebuilding structure', 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 diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index 3625edd8896..8eb575081bc 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -3,10 +3,8 @@ // 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. +// 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. import { useAppStore } from '@/store' import type { AppState } from '@/store/types' @@ -20,10 +18,6 @@ import { import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { pairedServerMirrors } from './paired-server-workspace' import { splitWorkspaceSessionByHost } from '@/lib/workspace-session-host-split' -import { - isWindowSessionWriteBusy, - onWindowSessionWriteIdle -} from '@/lib/window-session-write-activity' 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' @@ -44,6 +38,11 @@ import { onTabPaneTreeRedraw, takeTabRowFields } from './layout-owner-pane-intents' +import { + isDrawnGroupLayoutHeld, + isDrawnTabHeld, + onDrawnTabsRedraw +} from './layout-owner-drawn-tab-holds' import { subscribeRuntimeLayout } from './runtime-layout-client' type FrameSyncStore = { @@ -154,15 +153,15 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa built, keys, isTabPaneTreeHeld, - takeTabRowFields + takeTabRowFields, + HELD_DRAWN_TABS ) if (Object.keys(patch).length > 0) { store.setState(patch) } } - // Main keeps its own pane trees over the window's saves, so they show at once, even while the - // window owes main a write; only a tab the window's own pane command holds waits. + // 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() @@ -198,7 +197,6 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa } const state = store.getState() const drawn = framesDrawWorkspace(state) - const busy = isWindowSessionWriteBusy() const nextShown: Record = {} const structural: string[] = [] const titleUpdates: TerminalTabTitleUpdate[] = [] @@ -221,10 +219,6 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa ) ) nextShown[key] = layout - } else if (busy) { - if (previous) { - nextShown[key] = previous - } } else { structural.push(key) nextShown[key] = layout @@ -247,7 +241,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa 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. + // Coalesces the frames of one task. queueMicrotask(flush) } } @@ -281,11 +275,20 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa endLayoutFramePositions() } }, - // After the frame's flush. A busy write may still hold the frame's tabs (never its pane - // trees), so a waiting reply checks the store rather than assume the frame shows. + // After the frame's flush. A held tab may still show as the window drew it, so a waiting reply + // checks the store rather than assume the frame shows. (seq) => queueMicrotask(() => streamOpen && noteLayoutFramePosition(seq)) ) - const stopIdleListener = onWindowSessionWriteIdle(scheduleFlush) + // A released tab shows main's copy: its workspaces draw from the cache again. + const stopHeldTabsListener = onDrawnTabsRedraw((workspaces) => { + if (!streamOpen) { + return + } + 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) => { // A closed stream's cache is frozen: the window draws its own answers then. @@ -311,7 +314,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa }, close: () => { live = false - stopIdleListener() + stopHeldTabsListener() stopReleaseListener() stream.close() }, @@ -333,6 +336,8 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa } } +const HELD_DRAWN_TABS = { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld } + let startupFrames: WorkspaceLayoutFrameSync | null = null /** After restore: the window's one layout stream shows the frames' workspaces from now on. */ 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 eabc7a7861d..f80994e8c18 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 { beforeEach, describe, expect, it, vi } from 'vitest' +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 { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' @@ -19,7 +19,6 @@ import { bootWindowFromPartitions, projectStartupWindowView } 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 { AppState } from '../store/types' @@ -220,10 +219,6 @@ const CASES: Case[] = [ } ] -beforeEach(() => { - reportWindowSessionWriteActivity(false) -}) - describe('tab properties through main', () => { it.each(CASES.map((entry) => [entry.name, entry] as const))( '%s: main’s frame shows what the window’s own action shows, and restarts the same', diff --git a/src/renderer/src/store/slices/editor-layout-owner-records.test.ts b/src/renderer/src/store/slices/editor-layout-owner-records.test.ts index 232bf8ceabb..e917539df36 100644 --- a/src/renderer/src/store/slices/editor-layout-owner-records.test.ts +++ b/src/renderer/src/store/slices/editor-layout-owner-records.test.ts @@ -106,6 +106,8 @@ function applySent(): LayoutCommand[] { } async function showMainFrame(): Promise { + // Main has answered the window's commands, so no tab of theirs is still held as drawn. + await new Promise((resolve) => setTimeout(resolve, 0)) const sync = openWorkspaceLayoutFrameSync(useAppStore) stream.onFrame!({ type: 'snapshot', diff --git a/src/renderer/src/store/slices/store-terminal-tab-changes-layout-owner.test.ts b/src/renderer/src/store/slices/store-terminal-tab-changes-layout-owner.test.ts index 54b39347f86..5cafdcb4996 100644 --- a/src/renderer/src/store/slices/store-terminal-tab-changes-layout-owner.test.ts +++ b/src/renderer/src/store/slices/store-terminal-tab-changes-layout-owner.test.ts @@ -102,8 +102,8 @@ describe('a terminal tab’s own changes in a layout-owner build', () => { }) it('leaves the window’s color as it is when main refuses', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'invalid_params' }) const { store, tabId, tabBarId } = storeWithTab() + mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'invalid_params' }) store.getState().setTabColor(tabId, '#f00') diff --git a/src/renderer/src/store/terminals/terminal-tab-close-layout-owner.ts b/src/renderer/src/store/terminals/terminal-tab-close-layout-owner.ts index 1c76b462569..e31858112a0 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close-layout-owner.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close-layout-owner.ts @@ -5,8 +5,8 @@ import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' /** * A layout-owner build's record of a terminal tab this window closed: main records it through - * `layout.apply`, or today's close intent does when main cannot. False when a paired server owns - * the workspace's layout; the caller records it as today. + * `layout.apply`, or today's close intent does for a twin a paired server may hold. False when a + * paired server owns the workspace's layout; the caller records it as today. */ export function recordTerminalTabCloseThroughLayoutOwner( state: WorktreeRuntimeOwnerState & Pick, @@ -26,7 +26,8 @@ export function recordTerminalTabCloseThroughLayoutOwner( { type: 'closeTabs', tabIds: [entryId], recordClose: reason }, { apply: keep, - asToday: () => commitTerminalSurfaceClose(worktreeId, { kind: 'tab', tabId }, reason) + asToday: () => commitTerminalSurfaceClose(worktreeId, { kind: 'tab', tabId }, reason), + drawn: true } ) } diff --git a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.test.ts b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.test.ts index 8397ad09c69..d9959c2846a 100644 --- a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.test.ts +++ b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.test.ts @@ -12,20 +12,26 @@ import { takeTabRowFields } from '@/runtime/layout-owner-pane-intents' import { createTestStore, makeTab } from '../slices/store-test-helpers' -import { createInitialTerminalThroughLayoutOwner } from './terminal-tab-create-through-layout-owner' +import { + createInitialTerminalThroughLayoutOwner, + recordCreatedTerminalTabInLayoutOwner +} from './terminal-tab-create-through-layout-owner' const mocks = vi.hoisted(() => { const held: { pairedServer: string | null + twin: boolean reply: ((value: LayoutApplyReply) => void) | null fail: ((error: Error) => void) | null - } = { pairedServer: null, reply: null, fail: null } + } = { pairedServer: null, twin: false, reply: null, fail: null } return Object.assign(held, { callRuntimeRpc: vi.fn() }) }) vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('@/lib/resolve-owner', () => ({ - runtimeEnvironmentIdUnlessTwin: () => mocks.pairedServer + runtimeEnvironmentIdUnlessTwin: () => mocks.pairedServer, + runtimeEnvironmentIdForWorkspaceOwner: () => + mocks.twin ? { kind: 'ambiguous' } : { kind: 'resolved', environmentId: null } })) const WORKTREE = 'repo1::/path/wt' @@ -52,6 +58,7 @@ beforeEach(() => { _resetLayoutOwnerPaneIntentsForTests() endLayoutFramePositions() mocks.pairedServer = null + mocks.twin = false mocks.callRuntimeRpc.mockReset() mocks.callRuntimeRpc.mockImplementation( () => @@ -81,17 +88,17 @@ describe('createInitialTerminalThroughLayoutOwner', () => { expect(takeTabRowFields(command.tabId)).toEqual({ pendingActivationSpawn: true }) }) - it('draws main’s tab itself, under main’s ids, when no frame is coming', async () => { + it('draws nothing itself when no frame is coming: the reopened stream’s snapshot brings it', async () => { const { create, command } = start() mocks.reply?.({ ok: true, result: { tabId: command.tabId, leafId: command.leafId } }) await settle() - expect(create).toHaveBeenCalledWith({ tabId: command.tabId, leafId: command.leafId }) - expect(takeTabRowFields(command.tabId)).toBeNull() + expect(create).not.toHaveBeenCalled() + expect(takeTabRowFields(command.tabId)).toEqual({ pendingActivationSpawn: true }) }) - it('waits for a stream not yet in, and draws main’s tab itself if it ends first', async () => { + it('draws nothing itself when the stream ends before main’s frame', async () => { _resetLayoutFramePositionForTests() beginLayoutFramePositions() const { create, command } = start() @@ -102,10 +109,10 @@ describe('createInitialTerminalThroughLayoutOwner', () => { frameSeq: 1 }) await settle() - expect(create).not.toHaveBeenCalled() - endLayoutFramePositions() - expect(create).toHaveBeenCalledWith({ tabId: command.tabId, leafId: command.leafId }) + + expect(create).not.toHaveBeenCalled() + expect(takeTabRowFields(command.tabId)).toEqual({ pendingActivationSpawn: true }) }) it('draws nothing itself when a frame showed the tab before the stream ended', async () => { @@ -174,3 +181,29 @@ describe('createInitialTerminalThroughLayoutOwner', () => { expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) }) + +describe('recordCreatedTerminalTabInLayoutOwner', () => { + const command = { + type: 'createTerminalTab' as const, + workspace: WORKTREE, + tabId: 'tab-drawn', + leafId: 'leaf-drawn' + } + + it.each([ + [false, 1], + [true, 0] + ])( + 'on host_not_owned (twin: %s), closes the drawn tab unless a paired server may hold it', + async (twin, closes) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.twin = twin + const state = stateWith(['tab-drawn']) + const closeTab = vi.fn() + recordCreatedTerminalTabInLayoutOwner({ ...state, closeTab }, command) + mocks.reply?.({ ok: false, code: 'host_not_owned' }) + await settle() + expect(closeTab).toHaveBeenCalledTimes(closes) + } + ) +}) diff --git a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts index a2ec52f584b..9c5b3b71383 100644 --- a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts +++ b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts @@ -6,9 +6,10 @@ // window's save of it changes nothing in main. import { createBrowserUuid } from '@/lib/browser-uuid' -import { afterLayoutFrame, layoutFramesFlowing } from '@/runtime/layout-frame-position' +import { afterLayoutFrame } from '@/runtime/layout-frame-position' import { expectTabRowFields } from '@/runtime/layout-owner-pane-intents' import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner' +import { sendLayoutApply } from '@/runtime/layout-apply-send' import { newGroupSplitOf } from '@/runtime/layout-owner-new-group-split' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { layoutFrameTerminalTab } from '@/runtime/layout-frame-terminal-panes' @@ -86,49 +87,41 @@ export function terminalTabCreateCommand( } /** - * Sends a tab the window just drew to main. Nothing to undo on any answer: main's frame is what - * stays drawn, since the window's session save no longer carries the tab. A refusal is logged. + * Sends a tab the window just drew to main; frames keep it as drawn until main answers. A refusal + * is logged and the window closes the tab, stopping its terminal, since main's frame will not + * have it. A twin that main may route to a paired server keeps it: the window's save carries the + * server's partition as it is. */ export function recordCreatedTerminalTabInLayoutOwner( state: AppState, command: CommandOf<'createTerminalTab'> & { tabId: string; leafId: string } ): void { - // Why: a paired server mirrors this workspace's tabs; main does not own its layout. A tab main - // already holds is its own. - if ( - // Why: as `sendLayoutApply`, a twin stays on main's path: it edits this window's layout. - runtimeEnvironmentIdUnlessTwin(state, command.workspace) !== null || - layoutFrameTerminalTab(command.workspace, command.tabId) - ) { + // Why: a tab main already holds is its own. + if (layoutFrameTerminalTab(command.workspace, command.tabId)) { return } - void callRuntimeRpc({ kind: 'local' }, 'layout.apply', { command }).then( - (reply) => { - if (!reply.ok) { - console.warn('[layout-owner] createTerminalTab refused:', reply.code) - } - }, - (error: unknown) => { - console.warn( - '[layout-owner] createTerminalTab refused: call_failed', - error instanceof Error ? error.message : error - ) - } - ) + const { workspace, ...rest } = command + const keep = (): void => {} + sendLayoutApply(state, workspace, rest, { + apply: keep, + asToday: keep, + refused: () => state.closeTab(command.tabId, { reason: 'cleanup' }), + drawn: true + }) } /** * A workspace's first terminal on activation (F23). Main creates it only into a workspace its copy * holds no tab of, so this window and the runtime asking at once still make one tab. Main's frame - * draws it, with the window's own row fields; the window draws main's tab itself, under main's - * ids, only when no frame is coming (the stream is down). False when a paired server owns the - * workspace's layout. `create` with no ids opens the terminal as today: main holds no partition - * for the workspace. + * draws it, with the window's own row fields; while the stream is down, the reopened stream's + * snapshot does. False when a paired server owns the workspace's layout. `create` opens the + * terminal as today: main does not own the workspace (a twin it routes to a paired + * server, whose partition the window's save carries), or the call threw and was rolled back. */ export function createInitialTerminalThroughLayoutOwner( getState: () => AppState, worktreeId: string, - create: (ids?: { tabId: string; leafId: string }) => void + create: () => void ): boolean { if (runtimeEnvironmentIdUnlessTwin(getState(), worktreeId) !== null) { return false @@ -157,14 +150,8 @@ export function createInitialTerminalThroughLayoutOwner( (reply) => // After the frame with main's tab: the stream may end first, and the window draws it then. afterLayoutFrame(reply.frameSeq, () => { + // Main's frame draws it, a reopened stream's snapshot too. if (reply.ok && reply.result.tabId === tabId) { - // Why the store check: a frame may have drawn it before the stream ended. - if (!layoutFramesFlowing()) { - release() - if (!holdsTerminalTab(getState(), tabId)) { - create({ tabId, leafId }) - } - } return } // No tab id: main's copy already shows a tab here, which its frames bring. @@ -196,7 +183,3 @@ export function createInitialTerminalThroughLayoutOwner( /** The window's own mark on a first terminal: its spawn does not count as workspace activity. */ const ACTIVATION_ROW_FIELDS = { pendingActivationSpawn: true } as const - -function holdsTerminalTab(state: AppState, tabId: string): boolean { - return Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === tabId)) -} diff --git a/src/renderer/src/store/workspace-layout-frame-held-tabs.ts b/src/renderer/src/store/workspace-layout-frame-held-tabs.ts new file mode 100644 index 00000000000..9778043e032 --- /dev/null +++ b/src/renderer/src/store/workspace-layout-frame-held-tabs.ts @@ -0,0 +1,201 @@ +// Builds frame slices only and never calls set: the store patch applies what it returns. +// Slices rebuilt from a frame, with each tab the window's own command still holds put back as the +// store shows it (present, gone, or where the window placed it), and the rest as main has it. A +// group's order keeps main's for the tabs nothing holds. + +import type { Tab, TabGroup } from '../../../shared/tab-types' +import { isEditorTabContentType } from './slices/editor/tabs/editor-tab-content-type' +import type { LayoutFrameSlices } from './workspace-layout-frame-slices' +import type { LayoutFrameStoreState } from './workspace-layout-frame-store-patch' + +export type HeldDrawnTabs = { + /** By tab-bar or entity id. */ + isTabHeld: (workspace: string, id: string) => boolean + isGroupLayoutHeld: (workspace: string) => boolean +} + +export const NO_HELD_TABS: HeldDrawnTabs = { + isTabHeld: () => false, + isGroupLayoutHeld: () => false +} + +/** `built`'s records with the held ones as `existing` has them: kept, dropped or added. */ +function asDrawn( + existing: readonly T[] | undefined, + built: T[] | undefined, + heldIds: ReadonlySet, + idOf: (record: T) => string +): T[] | undefined { + const drawn = new Map( + (existing ?? []).filter((record) => heldIds.has(idOf(record))).map((r) => [idOf(r), r]) + ) + if (drawn.size === 0 && !(built ?? []).some((record) => heldIds.has(idOf(record)))) { + return built + } + const builtIds = new Set((built ?? []).map(idOf)) + const records = [ + ...(built ?? []).flatMap((record) => { + const id = idOf(record) + return heldIds.has(id) ? (drawn.has(id) ? [drawn.get(id)!] : []) : [record] + }), + ...[...drawn.values()].filter((record) => !builtIds.has(idOf(record))) + ] + return records.length > 0 || built ? records : undefined +} + +/** Main's order without the held tabs, each held tab put back after its neighbour in the store. */ +function heldOrder( + built: TabGroup | undefined, + existing: TabGroup | undefined, + heldIds: ReadonlySet +): string[] { + const order = (built?.tabOrder ?? []).filter((id) => !heldIds.has(id)) + const drawnOrder = existing?.tabOrder ?? [] + drawnOrder.forEach((id, index) => { + if (!heldIds.has(id)) { + return + } + const before = drawnOrder + .slice(0, index) + .toReversed() + .find((previous) => order.includes(previous)) + order.splice(before === undefined ? 0 : order.indexOf(before) + 1, 0, id) + }) + return order +} + +function heldGroups( + existing: readonly TabGroup[], + built: readonly TabGroup[], + heldIds: ReadonlySet +): TabGroup[] { + const touches = (group: TabGroup | undefined): boolean => + group?.tabOrder.some((id) => heldIds.has(id)) ?? false + const existingById = new Map(existing.map((group) => [group.id, group])) + const builtIds = new Set(built.map((group) => group.id)) + const reconcile = (main: TabGroup | undefined, drawn: TabGroup | undefined): TabGroup[] => { + if (!touches(main) && !touches(drawn)) { + return main ? [main] : [] + } + const tabOrder = heldOrder(main, drawn, heldIds) + // A group only main still has, left with no tab: the window closed or moved its last one. + if (!drawn && tabOrder.length === 0) { + return [] + } + const base = (drawn ?? main)! + const activeTabId = + [drawn?.activeTabId, main?.activeTabId].find((id) => id && tabOrder.includes(id)) ?? + tabOrder[0] ?? + null + return [{ ...base, tabOrder, activeTabId }] + } + return [ + ...built.flatMap((group) => reconcile(group, existingById.get(group.id))), + ...existing + .filter((group) => !builtIds.has(group.id) && touches(group)) + .flatMap((group) => reconcile(undefined, group)) + ] +} + +function entityIds(tabs: readonly Tab[], kind: (tab: Tab) => boolean): Set { + return new Set(tabs.filter(kind).map((tab) => tab.entityId)) +} + +/** `built` for `keys`, with every tab `held` names as `state` draws it. */ +export function withHeldTabsAsDrawn( + state: LayoutFrameStoreState, + built: LayoutFrameSlices, + keys: readonly string[], + held: HeldDrawnTabs +): LayoutFrameSlices { + let next = built + for (const key of keys) { + const isHeld = (tab: Tab): boolean => + held.isTabHeld(key, tab.id) || held.isTabHeld(key, tab.entityId) + const heldTabs = [ + ...(state.unifiedTabsByWorktree[key] ?? []), + ...(built.unifiedTabsByWorktree[key] ?? []) + ].filter(isHeld) + const layoutHeld = held.isGroupLayoutHeld(key) + if (heldTabs.length === 0 && !layoutHeld) { + continue + } + const heldIds = new Set(heldTabs.map((tab) => tab.id)) + const groups = heldGroups( + state.groupsByWorktree[key] ?? [], + next.groupsByWorktree[key] ?? [], + heldIds + ) + const groupIds = (list: readonly TabGroup[]): string => + list + .map((group) => group.id) + .sort() + .join('\0') + const layout = + layoutHeld || groupIds(groups) !== groupIds(next.groupsByWorktree[key] ?? []) + ? state.layoutByWorktree[key] + : next.layoutByWorktree[key] + const terminals = entityIds(heldTabs, (tab) => tab.contentType === 'terminal') + const browsers = entityIds(heldTabs, (tab) => tab.contentType === 'browser') + const files = entityIds(heldTabs, (tab) => isEditorTabContentType(tab.contentType)) + + const terminalLayouts = { ...next.terminalLayoutsByTabId } + for (const id of terminals) { + if (id in state.terminalLayoutsByTabId) { + terminalLayouts[id] = state.terminalLayoutsByTabId[id]! + } else { + delete terminalLayouts[id] + } + } + // A browser tab or file the window closed brings back none of its pages or record. + const drawnBrowsers = new Set((state.browserTabsByWorktree[key] ?? []).map((tab) => tab.id)) + const closedBrowsers = [...browsers].filter((id) => !drawnBrowsers.has(id)) + const closedPages = new Set( + closedBrowsers.flatMap((id) => (next.browserPagesByWorkspace[id] ?? []).map((p) => p.id)) + ) + const drawnFiles = new Set( + state.openFiles.filter((file) => file.worktreeId === key).map((file) => file.id) + ) + next = { + ...next, + unifiedTabsByWorktree: withKey( + next.unifiedTabsByWorktree, + key, + asDrawn(state.unifiedTabsByWorktree[key], next.unifiedTabsByWorktree[key], heldIds, idOf) + ), + groupsByWorktree: withKey(next.groupsByWorktree, key, groups), + layoutByWorktree: withKey(next.layoutByWorktree, key, layout), + tabsByWorktree: withKey( + next.tabsByWorktree, + key, + asDrawn(state.tabsByWorktree[key], next.tabsByWorktree[key], terminals, idOf) + ), + terminalLayoutsByTabId: terminalLayouts, + browserTabsByWorktree: withKey( + next.browserTabsByWorktree, + key, + asDrawn(state.browserTabsByWorktree[key], next.browserTabsByWorktree[key], browsers, idOf) + ), + browserPagesByWorkspace: Object.fromEntries( + Object.entries(next.browserPagesByWorkspace).filter(([id]) => !closedBrowsers.includes(id)) + ), + remoteBrowserPageHandlesByPageId: Object.fromEntries( + Object.entries(next.remoteBrowserPageHandlesByPageId).filter(([id]) => !closedPages.has(id)) + ), + openFiles: next.openFiles.filter( + (file) => file.worktreeId !== key || !files.has(file.id) || drawnFiles.has(file.id) + ) + } + } + return next +} + +const idOf = (record: { id: string }): string => record.id + +function withKey(map: Record, key: string, value: T | undefined): Record { + if (value === undefined) { + const { [key]: _removed, ...rest } = map + return rest + } + return { ...map, [key]: value } +} diff --git a/src/renderer/src/store/workspace-layout-frame-store-patch.ts b/src/renderer/src/store/workspace-layout-frame-store-patch.ts index 33c4da505c9..6a150f11436 100644 --- a/src/renderer/src/store/workspace-layout-frame-store-patch.ts +++ b/src/renderer/src/store/workspace-layout-frame-store-patch.ts @@ -9,6 +9,11 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import { isEditorTabContentType } from './slices/editor/tabs/editor-tab-content-type' import type { AppState } from './types' import type { LayoutFrameSlices } from './workspace-layout-frame-slices' +import { + NO_HELD_TABS, + withHeldTabsAsDrawn, + type HeldDrawnTabs +} from './workspace-layout-frame-held-tabs' /** 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). */ @@ -126,13 +131,16 @@ function setKey( */ export function buildLayoutFrameStorePatch( state: LayoutFrameStoreState, - built: LayoutFrameSlices, + frameSlices: LayoutFrameSlices, keys: readonly string[], /** A tab whose pane layout stays as the store has it: the window's own pane command holds it. */ keepsTerminalLayout: (tabId: string) => boolean = () => false, /** This window's own fields for a row the frame creates. */ - newRowFields: (tabId: string) => Partial | null = () => null + newRowFields: (tabId: string) => Partial | null = () => null, + /** Tabs the window drew, closed or moved before main answered: they stay as the store has them. */ + held: HeldDrawnTabs = NO_HELD_TABS ): Partial { + const built = withHeldTabsAsDrawn(state, frameSlices, keys, held) const patch: Partial = {} const keyed = (slice: KeyedSlice, value: (key: string) => unknown): void => { let next: Record = state[slice]