fix(sidebar): transfer launch provenance on pane detach

This commit is contained in:
Brennan Benson
2026-08-23 13:48:12 -07:00
parent d20d60ffcb
commit 1674bd9e71
5 changed files with 139 additions and 4 deletions
@@ -323,6 +323,26 @@ describe('buildTitleDerivedAgentRows', () => {
expect(rows[0]?.entry.terminalTitle).toBe('Rename the auth helper')
})
it('keeps a live mirrored Cursor title that was also stamped as the tab default', () => {
const rows = buildWorktreeAgentRows({
tabs: [
makeTab('tab-1', {
launchAgent: 'cursor',
launchAgentLeafId: LEAF_ID_1,
defaultTitle: 'Cursor Agent'
})
],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Cursor Agent' } },
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']])
})
it('keeps a launched Codex pane visible under an arbitrary session title', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'codex', launchAgentLeafId: LEAF_ID_1 })],
@@ -3,7 +3,8 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/ter
import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from './terminal-helpers'
import {
resolveLaunchAgentLeafId,
stampLaunchAgentLeafIdOnFirstLayout
stampLaunchAgentLeafIdOnFirstLayout,
transferLaunchAgentLeafStampOnDetach
} from './launch-agent-leaf-stamp'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
@@ -116,3 +117,49 @@ describe('resolveLaunchAgentLeafId', () => {
).toBeUndefined()
})
})
describe('transferLaunchAgentLeafStampOnDetach', () => {
it('moves launch provenance with the detached leaf', () => {
const tabs = transferLaunchAgentLeafStampOnDetach({
tabs: [
makeTab({ id: 'source', launchAgent: 'cursor', launchAgentLeafId: LEAF_A }),
makeTab({ id: 'target' })
],
sourceTabId: 'source',
targetTabId: 'target',
detachedLeafId: LEAF_A
})
expect(tabs?.find((tab) => tab.id === 'source')).not.toHaveProperty('launchAgent')
expect(tabs?.find((tab) => tab.id === 'source')).not.toHaveProperty('launchAgentLeafId')
expect(tabs?.find((tab) => tab.id === 'target')).toMatchObject({
launchAgent: 'cursor',
launchAgentLeafId: LEAF_A
})
})
it('does not overwrite existing target launch provenance', () => {
expect(
transferLaunchAgentLeafStampOnDetach({
tabs: [
makeTab({ id: 'source', launchAgent: 'cursor', launchAgentLeafId: LEAF_A }),
makeTab({ id: 'target', launchAgent: 'codex', launchAgentLeafId: LEAF_B })
],
sourceTabId: 'source',
targetTabId: 'target',
detachedLeafId: LEAF_A
})
).toBeNull()
})
it('does not guess ownership for an unpinned launch', () => {
expect(
transferLaunchAgentLeafStampOnDetach({
tabs: [makeTab({ id: 'source', launchAgent: 'cursor' }), makeTab({ id: 'target' })],
sourceTabId: 'source',
targetTabId: 'target',
detachedLeafId: LEAF_A
})
).toBeNull()
})
})
@@ -48,6 +48,40 @@ export function stampLaunchAgentLeafIdOnFirstLayout(args: {
return nextTabs
}
/** Moves launch provenance with a detached leaf without overwriting existing target ownership. */
export function transferLaunchAgentLeafStampOnDetach(args: {
tabs: readonly TerminalTab[]
sourceTabId: string
targetTabId: string
detachedLeafId: string
}): TerminalTab[] | null {
const sourceIndex = args.tabs.findIndex((tab) => tab.id === args.sourceTabId)
const targetIndex = args.tabs.findIndex((tab) => tab.id === args.targetTabId)
const source = args.tabs[sourceIndex]
const target = args.tabs[targetIndex]
if (
!source?.launchAgent ||
source.launchAgentLeafId !== args.detachedLeafId ||
!target ||
target.launchAgent ||
target.launchAgentLeafId
) {
return null
}
const sourceWithoutLaunchProvenance = { ...source }
delete sourceWithoutLaunchProvenance.launchAgent
delete sourceWithoutLaunchProvenance.launchAgentLeafId
const nextTabs = [...args.tabs]
nextTabs[sourceIndex] = sourceWithoutLaunchProvenance
nextTabs[targetIndex] = {
...target,
launchAgent: source.launchAgent,
launchAgentLeafId: args.detachedLeafId
}
return nextTabs
}
function soleLeafIdOnFirstLayout(
previousLayout: TerminalLayoutSnapshot | undefined,
nextLayout: TerminalLayoutSnapshot
@@ -20,7 +20,13 @@ describe('syncPaneDetachPtyOwnership agent identity', () => {
},
tabsByWorktree: {
[worktreeId]: [
makeTab({ id: sourceTabId, worktreeId, ptyId: 'pty-droid' }),
makeTab({
id: sourceTabId,
worktreeId,
ptyId: 'pty-droid',
launchAgent: 'droid',
launchAgentLeafId: detachedLeafId
}),
makeTab({ id: targetTabId, worktreeId, ptyId: null })
]
},
@@ -113,6 +119,13 @@ describe('syncPaneDetachPtyOwnership agent identity', () => {
agent: 'antigravity',
shellForeground: false
})
const tabs = state.tabsByWorktree[worktreeId] ?? []
expect(tabs.find((tab) => tab.id === sourceTabId)).not.toHaveProperty('launchAgent')
expect(tabs.find((tab) => tab.id === sourceTabId)).not.toHaveProperty('launchAgentLeafId')
expect(tabs.find((tab) => tab.id === targetTabId)).toMatchObject({
launchAgent: 'droid',
launchAgentLeafId: detachedLeafId
})
expect(resolveWindowsShiftEnterEncodingForPane(state, targetPaneKey)).toBe('csi-u')
expect(resolveWindowsShiftEnterEncodingForPane(state, siblingPaneKey)).toBe('alt-enter')
})
+23 -2
View File
@@ -51,7 +51,10 @@ import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-ti
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
import { sweepRetiredTerminalTabState } from './retired-terminal-tab-state-sweep'
import { stampLaunchAgentLeafIdOnFirstLayout } from './launch-agent-leaf-stamp'
import {
stampLaunchAgentLeafIdOnFirstLayout,
transferLaunchAgentLeafStampOnDetach
} from './launch-agent-leaf-stamp'
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
import {
collectReleasedLeafIds,
@@ -3663,6 +3666,24 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const nextTabsByWorktree = detachedPtyId
? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId)
: sourceTabsByWorktree
const sourceWorktreeId = getTerminalTabOwnerWorktreeId(nextTabsByWorktree, sourceTabId)
const targetWorktreeId = getTerminalTabOwnerWorktreeId(nextTabsByWorktree, targetTabId)
const transferredTabs =
sourceWorktreeId !== null && sourceWorktreeId === targetWorktreeId
? transferLaunchAgentLeafStampOnDetach({
tabs: nextTabsByWorktree[sourceWorktreeId] ?? [],
sourceTabId,
targetTabId,
detachedLeafId
})
: null
const tabsByWorktree =
transferredTabs && sourceWorktreeId !== null
? { ...nextTabsByWorktree, [sourceWorktreeId]: transferredTabs }
: nextTabsByWorktree
if (transferredTabs) {
scheduleRuntimeGraphSync()
}
const directSshLedger = transferDirectSshPaneDetachLedger(s, {
detachedPtyId,
sourcePtyId: sourcePrimaryPtyId,
@@ -3675,7 +3696,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
ptyIdsByTabId: nextPtyIdsByTabId,
lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId,
...directSshLedger,
...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {})
...(tabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree } : {})
}
})
// Why: detach keeps the process and its pane key alive, so move resume/status authority to the new surface before the source closes.