From d20d60ffcbb44bb6d72d3341c602b278e44c4d65 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:28:44 -0700 Subject: [PATCH] fix(sidebar): stamp launch leaf on mirrored first layout Host snapshots skip setTabLayout, so the original-leaf pin never armed on paired web/SSH and could linger after launchAgent dropped. Stamp and drop the pin with the same resolver the store uses. STA-4774 --- .../worktree-title-derived-agent-rows.test.ts | 28 ++++++++ ...ssion-tabs-sync-terminal-mirroring.test.ts | 67 ++++++++++++++++++- .../src/runtime/web-session-tabs-sync.ts | 13 +++- .../slices/launch-agent-leaf-stamp.test.ts | 40 ++++++++++- .../store/slices/launch-agent-leaf-stamp.ts | 51 +++++++++++--- src/shared/workspace-session-schema.test.ts | 30 +++++++++ 6 files changed, 214 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index e3745ff34f3..db624175044 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -373,6 +373,20 @@ describe('buildTitleDerivedAgentRows', () => { expect(rows).toHaveLength(0) }) + it('does not treat a bare Terminal title as exit unless it matches the tab defaultTitle', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'codex', launchAgentLeafId: LEAF_ID_1 })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Terminal 1' } }, + ptyIdsByTabId: { 'tab-1': ['pty-codex'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows.map((row) => [row.agentType, row.state])).toEqual([['codex', 'idle']]) + }) + it('keeps a launched Cursor pane on a sole leaf even before launchAgentLeafId is stamped', () => { const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1', { launchAgent: 'cursor' })], @@ -415,6 +429,20 @@ describe('buildTitleDerivedAgentRows', () => { expect(rows).toHaveLength(0) }) + it('keeps launch identity on the original leaf after a sibling closes', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'cursor', launchAgentLeafId: LEAF_ID_1 })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Rename the auth helper' } }, + ptyIdsByTabId: { 'tab-1': ['pty-cursor'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows.map((row) => [row.agentType, row.state])).toEqual([['cursor', 'idle']]) + }) + // #8940: an OpenCode pane's own task text must not hand the row to Claude Code. it('keeps an OpenCode-launched pane OpenCode across its own status frames', () => { const frames: [string, string][] = [ diff --git a/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts index c0f975ec51f..c87e25ae1e9 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts @@ -55,6 +55,7 @@ describe('applyWebSessionTabsSnapshot', () => { id: mirroredId, ptyId: 'remote:web-env-1@@terminal-1', launchAgent: 'codex', + launchAgentLeafId: LEAF_ID, startupCwd: '/worktree/packages/web', title: 'host shell', worktreeId: WT @@ -87,13 +88,21 @@ describe('applyWebSessionTabsSnapshot', () => { color: null, sortOrder: 0, createdAt: NOW, - launchAgent: 'codex' + launchAgent: 'codex', + launchAgentLeafId: LEAF_ID } const patch = applyWebSessionTabsSnapshot( makeState({ tabsByWorktree: { [WT]: [existingTab] }, - ptyIdsByTabId: { [existingTab.id]: ['remote:web-env-1@@terminal-1'] } + ptyIdsByTabId: { [existingTab.id]: ['remote:web-env-1@@terminal-1'] }, + terminalLayoutsByTabId: { + [existingTab.id]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null + } + } }), makeSnapshot([ { @@ -116,6 +125,60 @@ describe('applyWebSessionTabsSnapshot', () => { title: 'zsh' }) expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgent).toBeUndefined() + expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgentLeafId).toBeUndefined() + }) + + it('does not recycle launch provenance onto a remaining mirrored sibling', () => { + const existingTab: TerminalTab = { + id: toWebTerminalSurfaceTabId('host-tab-1'), + ptyId: 'remote:web-env-1@@terminal-1', + worktreeId: WT, + title: 'Rename the auth helper', + defaultTitle: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW, + launchAgent: 'codex', + launchAgentLeafId: LEAF_ID + } + + const patch = applyWebSessionTabsSnapshot( + makeState({ + tabsByWorktree: { [WT]: [existingTab] }, + ptyIdsByTabId: { [existingTab.id]: ['remote:web-env-1@@terminal-2'] }, + terminalLayoutsByTabId: { + [existingTab.id]: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null + } + } + }), + makeSnapshot([ + { + type: 'terminal', + id: `host-tab-1::${SECOND_LEAF_ID}`, + title: 'Rename the auth helper', + parentTabId: 'host-tab-1', + leafId: SECOND_LEAF_ID, + isActive: true, + launchAgent: 'codex', + status: 'ready', + terminal: 'terminal-2' + } + ]), + ENV, + NOW + 1 + ) as Partial + + expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgent).toBe('codex') + expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgentLeafId).toBe(LEAF_ID) }) it('drops mirrored startup cwd when a later host snapshot omits it', () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 56b788ea7b4..c27d98dc6f4 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -34,6 +34,7 @@ import { buildRetiredTerminalTabStateSweepPatch, type RetiredTerminalTabSweepState } from '../store/slices/retired-terminal-tab-state-sweep' +import { resolveLaunchAgentLeafId } from '../store/slices/launch-agent-leaf-stamp' import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream' import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' @@ -1071,6 +1072,14 @@ function buildMirroredTerminalTabs( // Why: viewMode echoes back through host snapshots, so prefer the client's record during the echo window and adopt the host value only without a prior tab. const hostViewModeSurface = surfaces.find((surface) => surface.viewMode) const viewMode = existing ? existing.viewMode : hostViewModeSurface?.viewMode + // Why: host snapshots omit this pin; stamp the first sole leaf and keep it + // only while launchAgent remains so a remaining sibling cannot inherit it. + const launchAgentLeafId = resolveLaunchAgentLeafId({ + launchAgent, + existingLeafId: existing?.launchAgentLeafId, + previousLayout: existingLayout, + nextLayout: layout + }) return { tab: { id: localTabId, @@ -1092,9 +1101,7 @@ function buildMirroredTerminalTabs( createdAt: existing?.createdAt ?? now + index, // Why: launchAgent is host-owned lifecycle metadata; once the host omits it, don't resurrect stale startup intent. ...(launchAgent ? { launchAgent } : {}), - // Why: launchAgentLeafId is client-stamped from the first sole leaf; the - // host snapshot does not carry it, so dropping it would recycle identity. - ...(existing?.launchAgentLeafId ? { launchAgentLeafId: existing.launchAgentLeafId } : {}) + ...(launchAgentLeafId ? { launchAgentLeafId } : {}) }, hostTabId: parentTabId, ptyIds, diff --git a/src/renderer/src/store/slices/launch-agent-leaf-stamp.test.ts b/src/renderer/src/store/slices/launch-agent-leaf-stamp.test.ts index 977c6209b65..25e0b08af89 100644 --- a/src/renderer/src/store/slices/launch-agent-leaf-stamp.test.ts +++ b/src/renderer/src/store/slices/launch-agent-leaf-stamp.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from './terminal-helpers' -import { stampLaunchAgentLeafIdOnFirstLayout } from './launch-agent-leaf-stamp' +import { + resolveLaunchAgentLeafId, + stampLaunchAgentLeafIdOnFirstLayout +} from './launch-agent-leaf-stamp' const LEAF_A = '11111111-1111-4111-8111-111111111111' const LEAF_B = '22222222-2222-4222-8222-222222222222' @@ -78,3 +81,38 @@ describe('stampLaunchAgentLeafIdOnFirstLayout', () => { expect(stamped).toBeNull() }) }) + +describe('resolveLaunchAgentLeafId', () => { + it('stamps the first sole leaf while launch identity is live', () => { + expect( + resolveLaunchAgentLeafId({ + launchAgent: 'cursor', + existingLeafId: undefined, + previousLayout: emptyLayoutSnapshot(), + nextLayout: singlePaneLayoutSnapshot(LEAF_A) + }) + ).toBe(LEAF_A) + }) + + it('keeps the original pin after a remaining sibling becomes the sole leaf', () => { + expect( + resolveLaunchAgentLeafId({ + launchAgent: 'cursor', + existingLeafId: LEAF_A, + previousLayout: splitLayout(), + nextLayout: singlePaneLayoutSnapshot(LEAF_B) + }) + ).toBe(LEAF_A) + }) + + it('drops the pin when launch identity is gone', () => { + expect( + resolveLaunchAgentLeafId({ + launchAgent: undefined, + existingLeafId: LEAF_A, + previousLayout: singlePaneLayoutSnapshot(LEAF_A), + nextLayout: singlePaneLayoutSnapshot(LEAF_A) + }) + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/launch-agent-leaf-stamp.ts b/src/renderer/src/store/slices/launch-agent-leaf-stamp.ts index be9c7f64b3d..21776ce95d6 100644 --- a/src/renderer/src/store/slices/launch-agent-leaf-stamp.ts +++ b/src/renderer/src/store/slices/launch-agent-leaf-stamp.ts @@ -1,6 +1,26 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +/** + * Next persisted launch-leaf pin while rebuilding a tab. + * Drops with launchAgent so a later relaunch cannot inherit a stale leaf; + * never overwrites an existing pin — a remaining sibling is also a sole leaf. + */ +export function resolveLaunchAgentLeafId(args: { + launchAgent: TerminalTab['launchAgent'] + existingLeafId: TerminalTab['launchAgentLeafId'] + previousLayout: TerminalLayoutSnapshot | undefined + nextLayout: TerminalLayoutSnapshot +}): string | undefined { + if (!args.launchAgent) { + return undefined + } + if (args.existingLeafId) { + return args.existingLeafId + } + return soleLeafIdOnFirstLayout(args.previousLayout, args.nextLayout) +} + /** * Bind tab-scoped launchAgent to the first sole leaf the layout describes. * Later topologies must not overwrite it — a remaining sibling after a close @@ -12,19 +32,32 @@ export function stampLaunchAgentLeafIdOnFirstLayout(args: { previousLayout: TerminalLayoutSnapshot | undefined nextLayout: TerminalLayoutSnapshot }): TerminalTab[] | null { - if (args.previousLayout?.root) { - return null - } - const nextRoot = args.nextLayout.root - if (nextRoot?.type !== 'leaf' || !isTerminalLeafId(nextRoot.leafId)) { - return null - } const tabIndex = args.tabs.findIndex((tab) => tab.id === args.tabId) const tab = args.tabs[tabIndex] - if (!tab?.launchAgent || tab.launchAgentLeafId) { + const launchAgentLeafId = resolveLaunchAgentLeafId({ + launchAgent: tab?.launchAgent, + existingLeafId: tab?.launchAgentLeafId, + previousLayout: args.previousLayout, + nextLayout: args.nextLayout + }) + if (!tab || !launchAgentLeafId || launchAgentLeafId === tab.launchAgentLeafId) { return null } const nextTabs = [...args.tabs] - nextTabs[tabIndex] = { ...tab, launchAgentLeafId: nextRoot.leafId } + nextTabs[tabIndex] = { ...tab, launchAgentLeafId } return nextTabs } + +function soleLeafIdOnFirstLayout( + previousLayout: TerminalLayoutSnapshot | undefined, + nextLayout: TerminalLayoutSnapshot +): string | undefined { + if (previousLayout?.root) { + return undefined + } + const nextRoot = nextLayout.root + if (nextRoot?.type !== 'leaf' || !isTerminalLeafId(nextRoot.leafId)) { + return undefined + } + return nextRoot.leafId +} diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index c64136a5d45..3ac608806dc 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -200,6 +200,36 @@ describe('parseWorkspaceSession', () => { } }) + it('drops an invalid launchAgentLeafId without failing the whole session', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'cursor', + launchAgentLeafId: 'not-a-leaf-id' + } + ] + }, + terminalLayoutsByTabId: {} + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt[0].launchAgentLeafId).toBeUndefined() + expect(result.value.tabsByWorktree.wt[0].launchAgent).toBe('cursor') + } + }) + it('drops an unknown launchAgent without failing the whole session', () => { const result = parseWorkspaceSession({ activeRepoId: null,