From 1674bd9e71edc0a7a044b3d1f28a386bc8dca238 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:48:12 -0700 Subject: [PATCH] fix(sidebar): transfer launch provenance on pane detach --- .../worktree-title-derived-agent-rows.test.ts | 20 ++++++++ .../slices/launch-agent-leaf-stamp.test.ts | 49 ++++++++++++++++++- .../store/slices/launch-agent-leaf-stamp.ts | 34 +++++++++++++ ...erminal-pane-detach-agent-identity.test.ts | 15 +++++- src/renderer/src/store/slices/terminals.ts | 25 +++++++++- 5 files changed, 139 insertions(+), 4 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 db624175044..d108f1f4844 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 @@ -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 })], 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 25e0b08af89..9409d664c3a 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 @@ -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() + }) +}) 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 21776ce95d6..61fea6a4ff2 100644 --- a/src/renderer/src/store/slices/launch-agent-leaf-stamp.ts +++ b/src/renderer/src/store/slices/launch-agent-leaf-stamp.ts @@ -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 diff --git a/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts b/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts index a824c15f915..502ad346f79 100644 --- a/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts +++ b/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts @@ -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') }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index c5e6caa8766..8d5705980ab 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -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 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 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.