From 99c72ffa65a4cfe90bee98d2d8444c0de785fa38 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:14:17 -0700 Subject: [PATCH] Leave adopted live terminals' tabs to the layout updates in the layout-owner build (dark, build switch off by default) (#29710) * Leave adopted live terminals' tabs to main's frames in a layout-owner build (dark, build switch off by default) * Adopt only into a pane main's frames hold for that terminal; test a tab-bar id that differs from its terminal id * Count only a pane main binds to the adopted terminal; check the pane key's tab * Read frame-held panes through a small module so window-owned builds keep the frame sync out --- ...live-surface-adoption-layout-owner.test.ts | 189 ++++++++++++++++++ .../worktree-agent-live-surface-adoption.ts | 11 + .../runtime/layout-frame-terminal-panes.ts | 17 ++ .../runtime/workspace-layout-frame-sync.ts | 21 +- 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts create mode 100644 src/renderer/src/runtime/layout-frame-terminal-panes.ts 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 new file mode 100644 index 00000000000..089b998c37a --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { publishWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { + addWorkspace, + emptySession, + GIT_KEY, + leaf +} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture' +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' +import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners' + +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() } })) + +type FrameDouble = { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } +const frames = vi.hoisted((): FrameDouble => ({ onFrame: null })) +vi.mock('../runtime/runtime-layout-client', () => ({ + subscribeRuntimeLayout: ( + _target: unknown, + _params: unknown, + onFrame: (frame: WorkspaceLayoutChangeFrame) => void + ) => { + frames.onFrame = onFrame + return { close: () => {} } + } +})) + +createStoreSessionMockApi() + +const OTHER_PTY = `${GIT_KEY}@@aaaa0001` +const LEFT_PTY = `${GIT_KEY}@@aaaa0002` +const RIGHT_PTY = `${GIT_KEY}@@aaaa0003` + +type TerminalSpec = { + id: string + entityId?: string + leaves: [string, string?][] + split?: 'vertical' +} + +/** The window's copy: one shell tab. Main's: that tab and a split agent tab the window lacks, + * whose tab-bar id differs from its terminal id, as for a tab adopted from another window. */ +function sessions(splitPtyIds: [string?, string?] = [LEFT_PTY, RIGHT_PTY]): { + window: WorkspaceSessionState + main: WorkspaceSessionState +} { + const other: TerminalSpec = { id: 'tab-other', leaves: [[leaf(1), OTHER_PTY]] } + const split: TerminalSpec = { + id: 'entry-split', + entityId: 'tab-split', + leaves: [ + [leaf(2), splitPtyIds[0]], + [leaf(3), splitPtyIds[1]] + ], + split: 'vertical' + } + return { + window: addWorkspace(emptySession(), GIT_KEY, [{ id: 'group-a', tabs: [other] }]), + main: addWorkspace(emptySession(), GIT_KEY, [{ id: 'group-a', tabs: [other, split] }]) + } +} + +function snapshotOf(session: WorkspaceSessionState): WorkspaceLayoutChangeFrame { + const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext()) + return { + type: 'snapshot', + workspaces: Object.entries(layout.workspaces).map(([key, workspace]) => ({ + key, + layout: publishWorkspaceLayout(workspace, 'local') + })) + } +} + +const owners: LiveTerminalSurfaceOwnerIndex = new Map([ + [LEFT_PTY, { tabId: 'tab-split', paneKey: `tab-split:${leaf(2)}`, ptyId: LEFT_PTY }], + [RIGHT_PTY, { tabId: 'tab-split', paneKey: `tab-split:${leaf(3)}`, ptyId: RIGHT_PTY }] +]) + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('adopting live terminals in a layout-owner build', () => { + let sync: WorkspaceLayoutFrameSync | null = null + + beforeEach(() => { + reportWindowSessionWriteActivity(false) + sync?.close() + sync = null + provideLayoutFrameTerminalPanes(() => false) + }) + + it('makes no tab or pane of its own while frames are held; 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.holdsTerminalPane) + sync.start() + // The window owes main a save, so main's tree waits. + reportWindowSessionWriteActivity(true) + frames.onFrame!(snapshotOf(main)) + await flushMicrotasks() + const createTab = vi.spyOn(store.getState(), 'createTab') + const setTabLayout = vi.spyOn(store.getState(), 'setTabLayout') + + const adoption = await adoptLiveWorkspacePtySurfaces( + store.getState, + GIT_KEY, + [LEFT_PTY, RIGHT_PTY], + async () => owners + ) + + expect(adoption).toEqual({ surfaced: true, declinedPtyIds: [] }) + expect(createTab).not.toHaveBeenCalled() + expect(setTabLayout).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toEqual(['tab-other']) + + reportWindowSessionWriteActivity(false) + await flushMicrotasks() + const layout = store.getState().terminalLayoutsByTabId['tab-split'] + expect(layout?.root).toMatchObject({ + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: leaf(2) }, + second: { type: 'leaf', leafId: leaf(3) } + }) + expect(layout?.ptyIdsByLeafId).toEqual({ [leaf(2)]: LEFT_PTY, [leaf(3)]: RIGHT_PTY }) + // A later sweep finds both terminals owned. + await expect( + adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [LEFT_PTY, RIGHT_PTY], async () => { + throw new Error('not asked: nothing is unbound') + }) + ).resolves.toEqual({ surfaced: true, declinedPtyIds: [] }) + }) + + it.each([ + ['binds to another terminal', [`${GIT_KEY}@@aaaa0008`, `${GIT_KEY}@@aaaa0009`]], + ['leaves unbound', [undefined, undefined]] + ] satisfies [string, [string?, string?]][])( + 'declines a terminal whose pane main %s', + async (_, ptyIds) => { + const { window: windowSession, main } = sessions(ptyIds) + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }]) + sync = openWorkspaceLayoutFrameSync(store) + provideLayoutFrameTerminalPanes(sync.holdsTerminalPane) + sync.start() + reportWindowSessionWriteActivity(true) + frames.onFrame!(snapshotOf(main)) + await flushMicrotasks() + + await expect( + adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [LEFT_PTY], async () => owners) + ).resolves.toEqual({ surfaced: false, declinedPtyIds: [LEFT_PTY] }) + } + ) + + it('declines a terminal whose tab main does not hold, rather than making one', async () => { + const { window: windowSession } = sessions() + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }]) + sync = openWorkspaceLayoutFrameSync(store) + provideLayoutFrameTerminalPanes(sync.holdsTerminalPane) + sync.start() + frames.onFrame!(snapshotOf(windowSession)) + await flushMicrotasks() + const createTab = vi.spyOn(store.getState(), 'createTab') + + const adoption = await adoptLiveWorkspacePtySurfaces( + store.getState, + GIT_KEY, + [LEFT_PTY], + async () => owners + ) + + expect(adoption).toEqual({ surfaced: false, declinedPtyIds: [LEFT_PTY] }) + expect(createTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 33df753468d..f623067239a 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -1,6 +1,8 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { parsePaneKey } from '../../../shared/stable-pane-id' import { worktreeIdsEqual } from '../../../shared/worktree/id' import type { useAppStore } from '@/store' +import { layoutFramesHoldTerminalPane } from '@/runtime/layout-frame-terminal-panes' import { resolveTerminalTabPtyOwnership } from './terminal-tab-for-pty-id' import type { LiveTerminalSurfaceOwner, @@ -105,6 +107,15 @@ function adoptHostOwnedSurface( ): boolean { const store = getState() const known = tabExists(store, owner.tabId) + // Switch point: main's frames draw a pane main holds for this terminal; the window makes no tab + // or pane of its own. Declined when main lacks it (main-first follow-up) or before the snapshot. + if (ORCA_LAYOUT_OWNER_IS_MAIN && !known) { + const pane = parsePaneKey(owner.paneKey) + return ( + pane?.tabId === owner.tabId && + layoutFramesHoldTerminalPane(worktreeId, { ...owner, leafId: pane.leafId }) + ) + } if (bindLivePtyToExactSurface(store, worktreeId, owner)) { if (!known) { materializedTabIds.add(owner.tabId) diff --git a/src/renderer/src/runtime/layout-frame-terminal-panes.ts b/src/renderer/src/runtime/layout-frame-terminal-panes.ts new file mode 100644 index 00000000000..f6e8e3f4ffd --- /dev/null +++ b/src/renderer/src/runtime/layout-frame-terminal-panes.ts @@ -0,0 +1,17 @@ +// Which terminal panes main's layout frames hold, for readers outside the frame sync. Its own +// module so those readers do not pull the frame sync into window-owned builds. + +type TerminalPane = { tabId: string; leafId: string; ptyId: string } +type TerminalPaneQuery = (worktreeId: string, pane: TerminalPane) => boolean + +let query: TerminalPaneQuery | null = null + +/** Set by the window's layout stream once it opens. */ +export function provideLayoutFrameTerminalPanes(next: TerminalPaneQuery): void { + query = next +} + +/** Whether main's frames will draw this terminal's pane; false before the window's stream opens. */ +export function layoutFramesHoldTerminalPane(worktreeId: string, pane: TerminalPane): boolean { + return query?.(worktreeId, pane) ?? false +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index 1294fcd0588..e8e9179129c 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -35,6 +35,7 @@ import { stableJson } from '../../../shared/workspace-layout/workspace-layout-lo import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' import { endLayoutFramePositions, noteLayoutFramePosition } from './layout-frame-position' +import { provideLayoutFrameTerminalPanes } from './layout-frame-terminal-panes' import { subscribeRuntimeLayout } from './runtime-layout-client' type FrameSyncStore = { @@ -46,6 +47,12 @@ export type WorkspaceLayoutFrameSync = { /** Shows every workspace the frames own once the snapshot is in, then keeps them current. */ start: () => void close: () => void + /** Whether main's latest frames show this terminal in this pane, drawn yet or not. An unbound + * pane does not count: nothing binds a frame-drawn pane to a live terminal later. */ + holdsTerminalPane: ( + worktreeId: string, + pane: { tabId: string; leafId: string; ptyId: string } + ) => boolean } /** A bucket, not a host: the view read-back routes the workspaces a rebuild skips here. */ @@ -235,7 +242,14 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa live = false stopIdleListener() stream.close() - } + }, + holdsTerminalPane: (worktreeId, { tabId, leafId, ptyId }) => + cache[worktreeId]?.tabs.some( + (tab) => + tab.entityId === tabId && + (tab.terminal?.panes.some((pane) => pane.leafId === leafId && pane.ptyId === ptyId) ?? + false) + ) ?? false } } @@ -244,6 +258,9 @@ let startupFrames: WorkspaceLayoutFrameSync | null = null /** After restore: the window's one layout stream shows the frames' workspaces from now on. */ export function showStartupLayoutFrames(): void { // Kept for the document's life: a local stream ends only with main, and a re-run boot reuses it. - startupFrames ??= openWorkspaceLayoutFrameSync(useAppStore) + if (!startupFrames) { + startupFrames = openWorkspaceLayoutFrameSync(useAppStore) + provideLayoutFrameTerminalPanes(startupFrames.holdsTerminalPane) + } startupFrames.start() }