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
This commit is contained in:
Brennan Benson
2026-08-20 20:28:44 -07:00
parent 40c7c39e5e
commit d20d60ffcb
6 changed files with 214 additions and 15 deletions
@@ -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][] = [
@@ -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<WebSessionTabsSyncState>
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', () => {
@@ -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,
@@ -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()
})
})
@@ -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
}
@@ -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,