diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts new file mode 100644 index 00000000000..1d284d64b3e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { + nativeChatLaunchAgentForLeaf, + resolveNativeChatLeafRoute +} from './native-chat-leaf-routing' + +describe('nativeChatLaunchAgentForLeaf', () => { + it('uses the tab launch hint only for its sole leaf', () => { + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'claude', + launchAgentLeafId: 'leaf-a', + leafId: 'leaf-a', + leafIds: ['leaf-a'] + }) + ).toBe('claude') + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'claude', + launchAgentLeafId: 'leaf-a', + leafId: 'leaf-b', + leafIds: ['leaf-a'] + }) + ).toBeNull() + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'claude', + launchAgentLeafId: 'leaf-a', + leafId: 'leaf-a', + leafIds: [] + }) + ).toBeNull() + }) + + it('does not lend the original launch agent to either leaf of a mixed split', () => { + const leafIds = ['agent-leaf', 'shell-leaf'] + + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'codex', + launchAgentLeafId: 'agent-leaf', + leafId: 'agent-leaf', + leafIds + }) + ).toBeNull() + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'codex', + launchAgentLeafId: 'agent-leaf', + leafId: 'shell-leaf', + leafIds + }) + ).toBeNull() + }) + + it('does not transfer the launch hint when the original leaf closes', () => { + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'codex', + launchAgentLeafId: 'closed-agent-leaf', + leafId: 'remaining-shell-leaf', + leafIds: ['remaining-shell-leaf'] + }) + ).toBeNull() + }) +}) + +describe('resolveNativeChatLeafRoute', () => { + it('keeps chat attached to its eligible leaf when focus moves to a shell sibling', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'agent-leaf', + activeLeafId: 'shell-leaf', + chatLeafStillMounted: true, + chatLeafIsEligible: true, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: 'agent-leaf', exitChat: false }) + }) + + it('moves chat to an eligible active sibling after its leaf closes', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'closed-leaf', + activeLeafId: 'agent-sibling', + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: 'agent-sibling', exitChat: false }) + }) + + it('moves chat to an eligible active sibling when its mounted leaf becomes ineligible', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'stopped-agent', + activeLeafId: 'agent-sibling', + chatLeafStillMounted: true, + chatLeafIsEligible: false, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: 'agent-sibling', exitChat: false }) + }) + + it('exits chat rather than inheriting an active shell after close', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'closed-agent', + activeLeafId: 'shell-leaf', + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: null, exitChat: true }) + }) + + it('exits chat when its leaf becomes ineligible and the active leaf is a shell', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'stopped-agent', + activeLeafId: 'shell-leaf', + chatLeafStillMounted: true, + chatLeafIsEligible: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: null, exitChat: true }) + }) + + it('attaches a tab-level chat request to the eligible active leaf', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: null, + activeLeafId: 'active-agent', + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: 'active-agent', exitChat: false }) + }) + + it('waits through manager hydration when there is no concrete active leaf', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'restored-agent', + activeLeafId: null, + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: 'restored-agent', exitChat: false }) + }) + + it('clears leaf ownership after returning to terminal view', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: false, + chatLeafId: 'agent-leaf', + activeLeafId: 'agent-leaf', + chatLeafStillMounted: true, + chatLeafIsEligible: true, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: null, exitChat: false }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts new file mode 100644 index 00000000000..f381f589014 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -0,0 +1,94 @@ +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + TuiAgent +} from '../../../../shared/types' + +function layoutNodeContainsLeaf(node: TerminalPaneLayoutNode | null, leafId: string): boolean { + if (!node) { + return false + } + if (node.type === 'leaf') { + return node.leafId === leafId + } + return layoutNodeContainsLeaf(node.first, leafId) || layoutNodeContainsLeaf(node.second, leafId) +} + +export function resolveNativeChatActiveLayoutLeafId( + layout: TerminalLayoutSnapshot | null | undefined +): string | null { + if (!layout) { + return null + } + if (layout.activeLeafId) { + // Why: close/hydration races can leave activeLeafId one snapshot behind + // the topology; stale pane evidence must not route chat to a removed leaf. + return !layout.root || layoutNodeContainsLeaf(layout.root, layout.activeLeafId) + ? layout.activeLeafId + : null + } + return layout.root?.type === 'leaf' ? layout.root.leafId : null +} + +export function isNativeChatTabWideFallbackSafe( + layout: TerminalLayoutSnapshot | null | undefined +): boolean { + if (!layout?.root) { + return true + } + if (layout.root.type === 'split') { + return false + } + // Why: a stale active id means the single-leaf collapse is not yet settled; + // tab-wide launch/title evidence could still describe the removed sibling. + return !layout.activeLeafId || layout.activeLeafId === layout.root.leafId +} + +export function nativeChatLaunchAgentForLeaf(args: { + launchAgent?: TuiAgent | null + launchAgentLeafId: string | null + leafId: string | null + leafIds: readonly string[] +}): TuiAgent | null { + const { launchAgent, launchAgentLeafId, leafId, leafIds } = args + if (!launchAgent || !launchAgentLeafId || !leafId) { + return null + } + // Why: launchAgent belongs to the tab's original pane. Once a split exists, + // it is not evidence that an agent is running in any particular sibling. + return leafIds.length === 1 && leafIds[0] === leafId && launchAgentLeafId === leafId + ? launchAgent + : null +} + +export type NativeChatLeafRoute = { + chatLeafId: string | null + exitChat: boolean +} + +export function resolveNativeChatLeafRoute(args: { + isChatViewMode: boolean + chatLeafId: string | null + activeLeafId: string | null + chatLeafStillMounted: boolean + chatLeafIsEligible: boolean + activeLeafIsEligible: boolean +}): NativeChatLeafRoute { + if (!args.isChatViewMode) { + return { chatLeafId: null, exitChat: false } + } + if (args.chatLeafId && args.chatLeafStillMounted && args.chatLeafIsEligible) { + return { chatLeafId: args.chatLeafId, exitChat: false } + } + // Manager hydration can briefly have no active pane; preserve the requested + // mode until a concrete leaf exists instead of toggling it off during mount. + if (!args.activeLeafId) { + return { chatLeafId: args.chatLeafId, exitChat: false } + } + if (args.activeLeafIsEligible) { + return { chatLeafId: args.activeLeafId, exitChat: false } + } + // Why: closing or invalidating the chat-owning leaf must not move its composer + // onto a plain-shell sibling. Return the tab to terminal mode instead. + return { chatLeafId: null, exitChat: true } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts index bc89a09539f..d2a037da269 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts @@ -1,15 +1,24 @@ import { describe, expect, it } from 'vitest' -import { - isNativeChatShortcutTitleFallbackSafe, - resolveNativeChatToggleShortcutDetectedAgent -} from './use-native-chat-toggle-shortcut' +import { isNativeChatTabWideFallbackSafe } from './native-chat-leaf-routing' +import { resolveNativeChatToggleShortcutDetectedAgent } from './use-native-chat-toggle-shortcut' + +const splitLayout = { + root: { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, leafId: 'leaf-1' }, + second: { type: 'leaf' as const, leafId: 'leaf-2' } + }, + activeLeafId: 'leaf-2', + expandedLeafId: null +} describe('resolveNativeChatToggleShortcutDetectedAgent', () => { it('uses the active split leaf instead of the first tab agent entry', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: 'tab-1', - activeLeafId: 'leaf-2', + terminalLayout: splitLayout, agentStatusByPaneKey: { 'tab-1:leaf-1': { agentType: 'gemini' }, 'tab-1:leaf-2': { agentType: 'codex' } @@ -22,7 +31,7 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: 'tab-1', - activeLeafId: 'leaf-2', + terminalLayout: splitLayout, agentStatusByPaneKey: { 'tab-1:leaf-1': { agentType: 'claude' }, 'tab-1:leaf-2': { agentType: 'grok' } @@ -35,7 +44,6 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: 'tab-1', - activeLeafId: null, agentStatusByPaneKey: { 'tab-2:leaf-1': { agentType: 'codex' }, 'tab-1:leaf-1': { agentType: 'claude' } @@ -43,24 +51,80 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { }) ).toBe('claude') }) + + it('does not inherit a split sibling before the active leaf is known', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { ...splitLayout, activeLeafId: null }, + agentStatusByPaneKey: { + 'tab-1:agent-leaf': { agentType: 'claude' }, + 'tab-1:shell-leaf': {} + } + }) + ).toBeNull() + }) + + it('uses the sole layout leaf when activeLeafId has not hydrated yet', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: null, + expandedLeafId: null + }, + agentStatusByPaneKey: { + 'tab-1:closed-leaf': { agentType: 'claude' }, + 'tab-1:leaf-2': { agentType: 'codex' } + } + }) + ).toBe('codex') + }) + + it('rejects a stale active leaf instead of reading its retained status', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null + }, + agentStatusByPaneKey: { + 'tab-1:closed-leaf': { agentType: 'claude' }, + 'tab-1:leaf-2': { agentType: 'codex' } + } + }) + ).toBeNull() + }) }) -describe('isNativeChatShortcutTitleFallbackSafe', () => { +describe('isNativeChatTabWideFallbackSafe', () => { it('allows title fallback before a layout snapshot exists', () => { - expect(isNativeChatShortcutTitleFallbackSafe(null)).toBe(true) + expect(isNativeChatTabWideFallbackSafe(null)).toBe(true) }) it('allows title fallback for a single leaf layout', () => { - expect(isNativeChatShortcutTitleFallbackSafe({ type: 'leaf', leafId: 'leaf-1' })).toBe(true) + expect( + isNativeChatTabWideFallbackSafe({ + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + }) + ).toBe(true) }) it('rejects title fallback for split layouts', () => { + expect(isNativeChatTabWideFallbackSafe(splitLayout)).toBe(false) + }) + + it('rejects title fallback while a collapsed layout still has a stale active id', () => { expect( - isNativeChatShortcutTitleFallbackSafe({ - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: 'leaf-1' }, - second: { type: 'leaf', leafId: 'leaf-2' } + isNativeChatTabWideFallbackSafe({ + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null }) ).toBe(false) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index c53b5263de0..3096077e820 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -1,31 +1,33 @@ import { useEffect } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import type { TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' import { getConnectionIdFromState } from '@/lib/connection-context' - -export function isNativeChatShortcutTitleFallbackSafe( - root: TerminalPaneLayoutNode | null | undefined -): boolean { - return !root || root.type === 'leaf' -} +import { + isNativeChatTabWideFallbackSafe, + resolveNativeChatActiveLayoutLeafId +} from './native-chat-leaf-routing' export function resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId, - activeLeafId, + terminalLayout, agentStatusByPaneKey }: { terminalTabId: string - activeLeafId: string | null + terminalLayout?: TerminalLayoutSnapshot | null agentStatusByPaneKey: Record }): AgentType | null { + const activeLeafId = resolveNativeChatActiveLayoutLeafId(terminalLayout) if (activeLeafId) { return agentStatusByPaneKey[`${terminalTabId}:${activeLeafId}`]?.agentType ?? null } + if (!isNativeChatTabWideFallbackSafe(terminalLayout)) { + return null + } return ( Object.entries(agentStatusByPaneKey).find(([paneKey]) => paneKey.startsWith(`${terminalTabId}:`) @@ -67,13 +69,13 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive // Pane keys are `${entityId}:${leafId}` — the backing terminal tab id, not // the unified tab id. const terminalLayout = state.terminalLayoutsByTabId[tab.entityId] - const activeLeafId = terminalLayout?.activeLeafId ?? null + const tabWideFallbackSafe = isNativeChatTabWideFallbackSafe(terminalLayout) const detectedAgent = resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: tab.entityId, - activeLeafId, + terminalLayout, agentStatusByPaneKey: state.agentStatusByPaneKey }) - const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root) + const titleFallbackAgent = tabWideFallbackSafe ? (resolveCommittedTitleAgentType(tab.label ?? '') ?? (terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null)) : null @@ -81,7 +83,7 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive !canToggleNativeChat({ experimentalNativeChatEnabled: state.settings?.experimentalNativeChat === true, contentType: 'terminal', - launchAgent: detectedAgent ? null : terminalTab?.launchAgent, + launchAgent: detectedAgent || !tabWideFallbackSafe ? null : terminalTab?.launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : titleFallbackAgent, nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 292952390bf..7a7cc8f0c21 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -81,7 +81,10 @@ import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' +import { + selectNativeChatTabWideFallbackUnsafeTabsById, + selectTabAgentTypesByTabId +} from './tab-agent-types-by-tab-id' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' const isWindows = navigator.userAgent.includes('Windows') @@ -449,16 +452,20 @@ function TabBarInner({ [unifiedTabs] ) - // Why: gate the tab long-press view-mode toggle to agent terminals. A tab is - // eligible when it launched an agent or has a live agent-status entry on any of - // its panes (paneKey = `${unifiedTabId}:…`), mirroring the toggle button's gate. + // Why: gate the tab long-press view-mode toggle to the agent in its active leaf. + // Tab-wide launch/title hints are safe only before the terminal is split. const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode) // Why: the strip only needs each tab's stable agent identity, but the whole // agentStatusByPaneKey map churns on every working↔idle transition app-wide. // Select a shallow-stable { tabId: agentType } projection so the strip // re-renders only when a tab gains/loses/changes its agent, not on status flips. const tabAgentTypesByTabId = useAppStore( - useShallow((s) => selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {})) + useShallow((s) => + selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {}, s.terminalLayoutsByTabId) + ) + ) + const nativeChatTabWideFallbackUnsafeTabsById = useAppStore( + useShallow((s) => selectNativeChatTabWideFallbackUnsafeTabsById(s.terminalLayoutsByTabId)) ) const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true) const nativeChatTranscriptIsLocalReadable = useAppStore((s) => @@ -1117,14 +1124,16 @@ function TabBarInner({ // agent-status pane keys are `${terminalTab.id}:${leafId}`, and // the unified tab id can differ from it. const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null + const tabWideFallbackSafe = + nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true const canToggleViewMode = unifiedTabForItem !== undefined && canToggleNativeChat({ experimentalNativeChatEnabled: nativeChatEnabled, contentType: 'terminal', - launchAgent: terminalTab.launchAgent, + launchAgent: tabWideFallbackSafe ? terminalTab.launchAgent : null, detectedAgent, - resolvedAgent, + resolvedAgent: tabWideFallbackSafe ? resolvedAgent : null, nativeChatTranscriptIsLocalReadable, isChatViewMode: unifiedTabForItem.viewMode === 'chat' }) diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts index 75ac3995670..727624abbef 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -1,13 +1,30 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' -import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' +import { + selectNativeChatTabWideFallbackUnsafeTabsById, + selectTabAgentTypesByTabId +} from './tab-agent-types-by-tab-id' function entry(partial: Partial): AgentStatusEntry { return { state: 'working', updatedAt: 0, ...partial } as AgentStatusEntry } +function splitLayout(activeLeafId: string | null): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + activeLeafId, + expandedLeafId: null + } +} + describe('selectTabAgentTypesByTabId', () => { it('maps each tab to its first pane agent type, matching findTabAgentEntry', () => { const map: Record = { @@ -36,6 +53,131 @@ describe('selectTabAgentTypesByTabId', () => { ) }) + it('uses the active split leaf regardless of pane-map insertion order', () => { + const layouts = { 'tab-1': splitLayout('leaf-b') } + const agentFirst = { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: 'codex' }) + } + const activeFirst = { + 'tab-1:leaf-b': entry({ agentType: 'codex' }), + 'tab-1:leaf-a': entry({ agentType: 'claude' }) + } + + expect(selectTabAgentTypesByTabId(agentFirst, layouts)['tab-1']).toBe('codex') + expect(selectTabAgentTypesByTabId(activeFirst, layouts)['tab-1']).toBe('codex') + expect(selectNativeChatTabWideFallbackUnsafeTabsById(layouts)).toEqual({ 'tab-1': true }) + }) + + it('does not inherit a supported sibling when the active split leaf is a shell', () => { + const projection = selectTabAgentTypesByTabId( + { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: undefined }) + }, + { 'tab-1': splitLayout('leaf-b') } + ) + + expect(projection['tab-1'] ?? null).toBeNull() + }) + + it('uses the reassigned active sibling after the prior agent leaf closes', () => { + const statuses = { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: 'codex' }) + } + + expect( + selectTabAgentTypesByTabId(statuses, { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-b' }, + activeLeafId: 'leaf-b', + expandedLeafId: null + } + })['tab-1'] + ).toBe('codex') + }) + + it('does not fall back to insertion order while a split has no active leaf', () => { + const projection = selectTabAgentTypesByTabId( + { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: undefined }) + }, + { 'tab-1': splitLayout(null) } + ) + + expect(projection['tab-1'] ?? null).toBeNull() + }) + + it('ignores a stale active leaf id that is no longer in the layout', () => { + const projection = selectTabAgentTypesByTabId( + { + 'tab-1:closed-leaf': entry({ agentType: 'claude' }), + 'tab-1:leaf-a': entry({ agentType: undefined }) + }, + { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null + } + } + ) + + expect(projection['tab-1'] ?? null).toBeNull() + expect( + selectNativeChatTabWideFallbackUnsafeTabsById({ + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null + } + }) + ).toEqual({ 'tab-1': true }) + }) + + it('uses the pane entry while a rootless layout is still hydrating', () => { + expect( + selectTabAgentTypesByTabId( + { 'tab-1:leaf-a': entry({ agentType: 'claude' }) }, + { 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } } + ) + ).toEqual({ 'tab-1': 'claude' }) + }) + + it('treats a missing layout map as no unsafe split evidence during hydration', () => { + expect(selectNativeChatTabWideFallbackUnsafeTabsById()).toEqual({}) + }) + + it('resolves the active leaf through nested splits and ignores expanded siblings', () => { + const layout: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-b' }, + second: { type: 'leaf', leafId: 'leaf-c' } + } + }, + activeLeafId: 'leaf-c', + expandedLeafId: 'leaf-a' + } + + expect( + selectTabAgentTypesByTabId( + { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-c': entry({ agentType: 'codex' }) + }, + { 'tab-1': layout } + ) + ).toEqual({ 'tab-1': 'codex' }) + }) + it('stays shallow-equal across a working<->idle status flip (no re-render)', () => { const working: Record = { 'tab-1:leaf-a': entry({ agentType: 'claude', state: 'working' }) diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts index 5fc8335a93c..68d78a43ab3 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -1,4 +1,9 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { + isNativeChatTabWideFallbackSafe, + resolveNativeChatActiveLayoutLeafId +} from '../native-chat/native-chat-leaf-routing' /** * Project `agentStatusByPaneKey` down to the stable `{ terminalTabId: agentType }` @@ -13,16 +18,34 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-statu * those transitions, so the strip re-renders only when a tab actually gains, loses, * or changes its agent. * - * First matching pane per tab wins, mirroring `findTabAgentEntry` exactly (tab ids - * are colon-free by construction, so the substring before the first `:` is the - * tab id). A pane whose entry has no `agentType` still claims the tab and yields - * `null`, identical to `findTabAgentEntry(...)?.agentType ?? null`. + * The active layout leaf wins when available because that is where a tab-level + * chat action opens. Before layout hydration, the first matching pane preserves + * the legacy lookup behavior (tab ids are colon-free by construction). */ export function selectTabAgentTypesByTabId( - agentStatusByPaneKey: Record + agentStatusByPaneKey: Record, + terminalLayoutsByTabId: Record = {} ): Record { const byTabId: Record = {} const claimed = new Set() + // Why: the tab action opens chat on the active split leaf, so that leaf's + // identity must outrank object insertion order from unrelated siblings. + for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { + // A rootless snapshot with no active leaf is hydration absence, not a + // topology decision; preserve the legacy tab lookup until a leaf exists. + if (!layout.root && !layout.activeLeafId) { + continue + } + claimed.add(tabId) + const activeLeafId = resolveNativeChatActiveLayoutLeafId(layout) + if (!activeLeafId) { + continue + } + const entry = agentStatusByPaneKey[`${tabId}:${activeLeafId}`] + if (entry?.agentType != null) { + byTabId[tabId] = entry.agentType + } + } for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { const colon = paneKey.indexOf(':') if (colon <= 0) { @@ -39,3 +62,17 @@ export function selectTabAgentTypesByTabId( } return byTabId } + +export function selectNativeChatTabWideFallbackUnsafeTabsById( + terminalLayoutsByTabId: Record = {} +): Record { + // Why: legacy and hydrating store shapes may not expose layout state yet; + // absence carries no unsafe split evidence and must not crash tab rendering. + const unsafeTabs: Record = {} + for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { + if (!isNativeChatTabWideFallbackSafe(layout)) { + unsafeTabs[tabId] = true + } + } + return unsafeTabs +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4f6e1ecbe9e..f9a4e1fc40f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -101,6 +101,10 @@ import { } from '@/lib/pane-manager/mobile-driver-state' import { shouldChatTakeOverMobileSurface } from '../native-chat/native-chat-send-eligibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' +import { + nativeChatLaunchAgentForLeaf, + resolveNativeChatLeafRoute +} from '../native-chat/native-chat-leaf-routing' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' @@ -376,10 +380,8 @@ export default function TerminalPane({ // list via managerRef.current?.getPanes()) re-runs when a pane is split or // closed. managerRef is imperative and doesn't trigger React's dependency // tracking. The lifecycle hook updates this via setPaneCount on - // onPaneCreated / onPaneClosed / onLayoutChanged. The value is never - // read — the portal map at line ~914 calls `managerRef.current?.getPanes()` - // imperatively, so `setPaneCount` is used only as a render-trigger side - // effect to force that map to re-run when a pane is split or closed. + // onPaneCreated / onPaneClosed / onLayoutChanged. The portal map reads the + // manager imperatively; the count also wakes leaf-ownership initialization. const [paneCount, setPaneCount] = useState(0) // Why: pane reorders can move panes without changing count or size, so // overlay rects need an explicit layout-change render trigger. @@ -394,6 +396,9 @@ export default function TerminalPane({ } | null>(null) const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false) const [chatLeafId, setChatLeafId] = useState(null) + const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState( + undefined + ) // Why: the terminal menu can be the first quick-command entry point, so each // Add action starts with a fresh draft instead of reusing cancelled text. const [quickCommandDraft, setQuickCommandDraft] = useState(createTerminalQuickCommandDraft) @@ -685,53 +690,106 @@ export default function TerminalPane({ selectTerminalTabAgentTypesByLeaf(store.agentStatusByPaneKey, tabId) ) const toggleTabViewMode = useAppStore((store) => store.toggleTabViewMode) + const setTabViewMode = useAppStore((store) => store.setTabViewMode) const savedLayout = useAppStore((store) => store.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT) const terminalTab = useAppStore((store) => getCachedTerminalTabForWorktree(store.tabsByWorktree, worktreeId, tabId) ) + const restoredLayout = useMemo( + () => (terminalTab ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminalTab) : savedLayout), + [savedLayout, terminalTab] + ) + const expectedLayoutLeafIds = useMemo( + () => collectLeafIdsInOrder(restoredLayout.root), + [restoredLayout.root] + ) + const getNativeChatLeafIds = useCallback((): string[] => { + const mountedLeafIds = managerRef.current?.getPanes().map((pane) => pane.leafId) ?? [] + // Why: a partially hydrated manager can expose one pane from a restored + // split. Union both sources so tab-wide evidence stays disabled meanwhile. + return [...new Set([...expectedLayoutLeafIds, ...mountedLeafIds])] + }, [expectedLayoutLeafIds]) + const getTabWideAgentHintLeafId = useCallback((): string | null => { + if (tabWideAgentHintLeafId !== undefined) { + return tabWideAgentHintLeafId + } + const leafIds = getNativeChatLeafIds() + return leafIds.length === 1 ? leafIds[0] : null + }, [getNativeChatLeafIds, tabWideAgentHintLeafId]) + useEffect(() => { + if (tabWideAgentHintLeafId !== undefined) { + return + } + const leafIds = getNativeChatLeafIds() + if (leafIds.length === 0) { + return + } + // Why: tab-wide launch/title metadata predates leaf ownership. Bind it only + // when the first concrete topology proves which sole leaf it can describe. + setTabWideAgentHintLeafId(leafIds.length === 1 ? leafIds[0] : null) + }, [getNativeChatLeafIds, paneCount, tabWideAgentHintLeafId]) const resolveTitleAgentForLeaf = useCallback( - (leafId: string | null) => - resolveNativeChatLeafTitleAgent({ + (leafId: string | null) => { + const hasSingleKnownLeaf = + getNativeChatLeafIds().length === 1 && getTabWideAgentHintLeafId() === leafId + return resolveNativeChatLeafTitleAgent({ leafId, panes: managerRef.current?.getPanes() ?? [], runtimePaneTitlesByPaneId, - tabLabel: unifiedTabLabel, - terminalTitle: terminalTab?.title - }), - [runtimePaneTitlesByPaneId, terminalTab?.title, unifiedTabLabel] + tabLabel: hasSingleKnownLeaf ? unifiedTabLabel : null, + terminalTitle: hasSingleKnownLeaf ? terminalTab?.title : null + }) + }, + [ + getNativeChatLeafIds, + getTabWideAgentHintLeafId, + runtimePaneTitlesByPaneId, + terminalTab?.title, + unifiedTabLabel + ] ) // Per-leaf eligibility: a split can mix a supported agent in one leaf with an // unsupported one in another, so the toggle is gated by the specific leaf. // A leaf's own live agent is authoritative; the tab-wide launch/title hints // only fill in before hooks arrive (or for the single-pane case) so they // can't enable the toggle on a sibling actually running an unsupported agent. - const canToggleChatForLeaf = useCallback( + const isChatEligibleForLeaf = useCallback( (leafId: string | null): boolean => { const detectedAgent = leafId ? (tabAgentTypeByLeaf[leafId] ?? null) : null - // Scope the "always allow toggling back" rule to the leaf actually showing - // chat — passing the tab-wide flag would re-enable the toggle on an - // unsupported sibling whenever any leaf in the split is in chat view. - const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId + const launchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminalTab?.launchAgent, + launchAgentLeafId: getTabWideAgentHintLeafId(), + leafId, + leafIds: getNativeChatLeafIds() + }) return canToggleNativeChat({ experimentalNativeChatEnabled: nativeChatEnabled, contentType: 'terminal', - launchAgent: detectedAgent ? null : terminalTab?.launchAgent, + launchAgent: detectedAgent ? null : launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId), - nativeChatTranscriptIsLocalReadable, - isChatViewMode: isChatViewForLeaf + nativeChatTranscriptIsLocalReadable }) }, [ tabAgentTypeByLeaf, - effectiveChatViewMode, - chatLeafId, nativeChatEnabled, nativeChatTranscriptIsLocalReadable, terminalTab?.launchAgent, + getNativeChatLeafIds, + getTabWideAgentHintLeafId, resolveTitleAgentForLeaf ] ) + const canToggleChatForLeaf = useCallback( + (leafId: string | null): boolean => { + // Scope the "always allow toggling back" rule to the leaf actually showing + // chat; it must not make an unsupported sibling look eligible. + const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId + return (nativeChatEnabled && isChatViewForLeaf) || isChatEligibleForLeaf(leafId) + }, + [chatLeafId, effectiveChatViewMode, isChatEligibleForLeaf, nativeChatEnabled] + ) const toggleNativeChatForLeaf = useCallback( (leafId: string) => { if (!unifiedTabId) { @@ -757,14 +815,6 @@ export default function TerminalPane({ toggleNativeChatForLeaf(activeLeafId) }, [toggleNativeChatForLeaf]) const setTabLayout = useAppStore((store) => store.setTabLayout) - const restoredLayout = useMemo( - () => (terminalTab ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminalTab) : savedLayout), - [savedLayout, terminalTab] - ) - const expectedLayoutLeafIds = useMemo( - () => collectLeafIdsInOrder(restoredLayout.root), - [restoredLayout.root] - ) const expectedLayoutLeafIdsAttr = expectedLayoutLeafIds.length > 0 ? expectedLayoutLeafIds.join(' ') : undefined const initialLayoutRef = useRef(restoredLayout) @@ -2920,23 +2970,31 @@ export default function TerminalPane({ ? managedPanes.some((pane) => pane.leafId === chatLeafId) : false useEffect(() => { - if (!isChatViewMode) { - if (chatLeafId !== null) { - setChatLeafId(null) - } - return - } const activeLeafId = activePane?.leafId ?? null - if (!chatLeafId) { - if (activeLeafId) { - setChatLeafId(activeLeafId) - } - return + const route = resolveNativeChatLeafRoute({ + isChatViewMode, + chatLeafId, + activeLeafId, + chatLeafStillMounted, + chatLeafIsEligible: isChatEligibleForLeaf(chatLeafId), + activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) + }) + if (route.chatLeafId !== chatLeafId) { + setChatLeafId(route.chatLeafId) } - if (!chatLeafStillMounted) { - setChatLeafId(activeLeafId) + if (route.exitChat && unifiedTabId) { + // Why: effect replay must not flip terminal mode back to chat. + setTabViewMode(unifiedTabId, 'terminal') } - }, [isChatViewMode, chatLeafId, activePane?.leafId, chatLeafStillMounted]) + }, [ + isChatViewMode, + chatLeafId, + activePane?.leafId, + chatLeafStillMounted, + isChatEligibleForLeaf, + unifiedTabId, + setTabViewMode + ]) const chatPane = isChatViewMode && chatLeafId ? (managedPanes.find((pane) => pane.leafId === chatLeafId) ?? null) @@ -2945,6 +3003,12 @@ export default function TerminalPane({ ? (paneTransportsRef.current.get(chatPane.id)?.getPtyId() ?? null) : null const chatPaneResolvedAgent = chatPane ? resolveTitleAgentForLeaf(chatPane.leafId) : null + const chatPaneLaunchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminalTab?.launchAgent, + launchAgentLeafId: getTabWideAgentHintLeafId(), + leafId: chatPane?.leafId ?? null, + leafIds: getNativeChatLeafIds() + }) const activePaneIsChatLeaf = Boolean( isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId ) @@ -3045,7 +3109,7 @@ export default function TerminalPane({ terminalTabId={tabId} paneKey={makePaneKey(tabId, chatPane.leafId)} targetPtyId={chatPanePtyId} - launchAgent={terminalTab?.launchAgent} + launchAgent={chatPaneLaunchAgent} resolvedAgent={chatPaneResolvedAgent} onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)} contextMenuActions={{ diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts new file mode 100644 index 00000000000..1fb24a000dd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + resolveTabTitleAfterPaneClose, + shouldClearLaunchAgentForClosedPane +} from './terminal-pane-close-identity' + +describe('shouldClearLaunchAgentForClosedPane', () => { + it('clears launch identity only when the launch-owning PTY closes', () => { + const tab = { launchAgent: 'codex' as const, ptyId: 'pty-agent' } + + expect(shouldClearLaunchAgentForClosedPane(tab, 'pty-agent')).toBe(true) + expect(shouldClearLaunchAgentForClosedPane(tab, 'pty-shell')).toBe(false) + }) + + it('does not mutate identity-free or not-yet-bound tabs', () => { + expect(shouldClearLaunchAgentForClosedPane({ ptyId: 'pty-1' }, 'pty-1')).toBe(false) + expect( + shouldClearLaunchAgentForClosedPane({ launchAgent: 'claude', ptyId: null }, 'pty-1') + ).toBe(false) + }) +}) + +describe('resolveTabTitleAfterPaneClose', () => { + it('uses the promoted sibling title when one is known', () => { + expect(resolveTabTitleAfterPaneClose({ 2: 'codex' }, 2)).toBe('codex') + }) + + it('resets to the tab fallback when the promoted shell has no title', () => { + expect(resolveTabTitleAfterPaneClose({ 1: 'closed agent' }, 2)).toBe('') + expect(resolveTabTitleAfterPaneClose({}, null)).toBe('') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts new file mode 100644 index 00000000000..2325241098f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts @@ -0,0 +1,19 @@ +import type { TerminalTab } from '../../../../shared/types' + +export function shouldClearLaunchAgentForClosedPane( + tab: Pick | null | undefined, + closedPtyId: string | null | undefined +): boolean { + // Why: launchAgent describes the tab's original PTY only. Closing that PTY + // must not transfer its bootstrap identity to a surviving shell sibling. + return Boolean(tab?.launchAgent && closedPtyId && tab.ptyId === closedPtyId) +} + +export function resolveTabTitleAfterPaneClose( + runtimePaneTitlesByPaneId: Readonly>, + activePaneId: number | null | undefined +): string { + // Why: an empty update resets the tab to its stable fallback instead of + // leaving the closed pane's agent title attached to an untitled survivor. + return activePaneId == null ? '' : (runtimePaneTitlesByPaneId[activePaneId] ?? '') +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index bfd6b988291..27d2c383f5d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -45,6 +45,10 @@ vi.mock('./pty-dispatcher', () => ({ })) type MockStoreState = { + tabsByWorktree: Record< + string, + { id: string; launchAgent?: 'claude' | 'codex'; ptyId: string | null }[] + > terminalLayoutsByTabId: Record< string, { @@ -55,8 +59,10 @@ type MockStoreState = { } > runtimePaneTitlesByTabId: Record> + clearTabLaunchAgent: ReturnType clearRuntimePaneTitle: ReturnType setTabLayout: ReturnType + updateTabTitle: ReturnType } let mockStoreState: MockStoreState @@ -102,10 +108,13 @@ function syncParked(args?: { describe('terminal-parked-tab-watchers', () => { beforeEach(() => { mockStoreState = { + tabsByWorktree: {}, terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {}, + clearTabLaunchAgent: vi.fn(), clearRuntimePaneTitle: vi.fn(), - setTabLayout: vi.fn() + setTabLayout: vi.fn(), + updateTabTitle: vi.fn() } ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } }) @@ -370,6 +379,64 @@ describe('terminal-parked-tab-watchers', () => { }) }) + it('retires launch/title hints when the launch-owning parked leaf exits', () => { + mockStoreState.tabsByWorktree = { + [WORKTREE_ID]: [{ id: TAB_ID, launchAgent: 'codex', ptyId: PTY_ID }] + } + mockStoreState.runtimePaneTitlesByTabId = { + [TAB_ID]: { 1: 'Codex', 2: 'PowerShell' } + } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(mockStoreState.clearTabLaunchAgent).toHaveBeenCalledWith(TAB_ID) + expect(mockStoreState.updateTabTitle).toHaveBeenCalledWith(TAB_ID, 'PowerShell') + }) + + it('keeps launch ownership when only a parked shell sibling exits', () => { + mockStoreState.tabsByWorktree = { + [WORKTREE_ID]: [{ id: TAB_ID, launchAgent: 'claude', ptyId: PTY_ID }] + } + mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: 'Claude Code' } } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + + expect(mockStoreState.clearTabLaunchAgent).not.toHaveBeenCalled() + expect(mockStoreState.updateTabTitle).toHaveBeenCalledWith(TAB_ID, 'Claude Code') + }) + it('keeps exit→closeTab parity for a parked single-leaf tab', () => { capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) syncParked() diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 8871bdf4905..ba86ba3e512 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -18,6 +18,10 @@ import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' import { subscribeToPtyExit } from './pty-dispatcher' import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking' +import { + resolveTabTitleAfterPaneClose, + shouldClearLaunchAgentForClosedPane +} from './terminal-pane-close-identity' import { capturedPanesByTabId, disposeParkedTabWatchers, @@ -232,7 +236,24 @@ function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { } const detached = detachTerminalLayoutLeaf(layout, leafId) if (detached) { + const terminalTab = Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + if (shouldClearLaunchAgentForClosedPane(terminalTab, ptyId)) { + state.clearTabLaunchAgent(tabId) + } state.setTabLayout(tabId, detached.sourceLayout) + const activeLeafId = detached.sourceLayout.activeLeafId + const activePtyId = activeLeafId + ? detached.sourceLayout.ptyIdsByLeafId?.[activeLeafId] + : undefined + const activePaneId = activePtyId + ? (parkedWatchersByTabId.get(tabId)?.paneIdByPtyId.get(activePtyId) ?? null) + : null + state.updateTabTitle( + tabId, + resolveTabTitleAfterPaneClose(state.runtimePaneTitlesByTabId[tabId] ?? {}, activePaneId) + ) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 5a64694000e..feeedbc6cd0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -127,6 +127,10 @@ import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state' +import { + resolveTabTitleAfterPaneClose, + shouldClearLaunchAgentForClosedPane +} from './terminal-pane-close-identity' export function recordRuntimeCreatedTerminalPaneSplit( createdPane: unknown, @@ -1278,6 +1282,13 @@ export function useTerminalPaneLifecycle({ mouseHideDisposablesRef.current.delete(paneId) } const transport = paneTransportsRef.current.get(paneId) + const closedPtyId = transport?.getPtyId() ?? null + const terminalTab = useAppStore + .getState() + .tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + if (!isDetachedToTab && shouldClearLaunchAgentForClosedPane(terminalTab, closedPtyId)) { + useAppStore.getState().clearTabLaunchAgent(tabId) + } const panePtyBinding = panePtyBindings.get(paneId) if (panePtyBinding) { panePtyBinding.dispose() @@ -1350,10 +1361,7 @@ export function useTerminalPaneLifecycle({ if (newActivePane) { reportActiveRendererPtyForPane(paneTransportsRef.current, newActivePane.id) const paneTitles = useAppStore.getState().runtimePaneTitlesByTabId[tabId] ?? {} - const activeTitle = paneTitles[newActivePane.id] - if (activeTitle) { - updateTabTitle(tabId, activeTitle) - } + updateTabTitle(tabId, resolveTabTitleAfterPaneClose(paneTitles, newActivePane.id)) } scheduleRuntimeGraphSync() },