From fcdfb70275c3cfe25689aa43f01b7a124a08ea9b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:34:21 -0700 Subject: [PATCH] fix(native-chat): target native chat to the active split leaf --- .../native-chat-leaf-routing.test.ts | 172 ++++++++++++++++++ .../native-chat/native-chat-leaf-routing.ts | 50 +++++ .../use-native-chat-toggle-shortcut.test.ts | 14 ++ .../use-native-chat-toggle-shortcut.ts | 15 +- .../src/components/tab-bar/TabBar.tsx | 22 ++- .../tab-bar/tab-agent-types-by-tab-id.test.ts | 94 +++++++++- .../tab-bar/tab-agent-types-by-tab-id.ts | 50 ++++- .../components/terminal-pane/TerminalPane.tsx | 150 ++++++++++----- 8 files changed, 507 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-leaf-routing.ts 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..37fa67ebb25 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -0,0 +1,50 @@ +import type { TuiAgent } from '../../../../shared/types' + +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..17954fe368d 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 @@ -43,6 +43,20 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { }) ).toBe('claude') }) + + it('does not inherit a split sibling before the active leaf is known', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + activeLeafId: null, + agentStatusByPaneKey: { + 'tab-1:agent-leaf': { agentType: 'claude' }, + 'tab-1:shell-leaf': {} + }, + allowTabFallback: false + }) + ).toBeNull() + }) }) describe('isNativeChatShortcutTitleFallbackSafe', () => { 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..a8aa6694d7a 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 @@ -17,15 +17,20 @@ export function isNativeChatShortcutTitleFallbackSafe( export function resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId, activeLeafId, - agentStatusByPaneKey + agentStatusByPaneKey, + allowTabFallback = true }: { terminalTabId: string activeLeafId: string | null agentStatusByPaneKey: Record + allowTabFallback?: boolean }): AgentType | null { if (activeLeafId) { return agentStatusByPaneKey[`${terminalTabId}:${activeLeafId}`]?.agentType ?? null } + if (!allowTabFallback) { + return null + } return ( Object.entries(agentStatusByPaneKey).find(([paneKey]) => paneKey.startsWith(`${terminalTabId}:`) @@ -68,12 +73,14 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive // the unified tab id. const terminalLayout = state.terminalLayoutsByTabId[tab.entityId] const activeLeafId = terminalLayout?.activeLeafId ?? null + const tabWideFallbackSafe = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root) const detectedAgent = resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: tab.entityId, activeLeafId, - agentStatusByPaneKey: state.agentStatusByPaneKey + agentStatusByPaneKey: state.agentStatusByPaneKey, + allowTabFallback: tabWideFallbackSafe }) - const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root) + const titleFallbackAgent = tabWideFallbackSafe ? (resolveCommittedTitleAgentType(tab.label ?? '') ?? (terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null)) : null @@ -81,7 +88,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..a151b8acb08 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 { + selectSplitTerminalTabsById, + 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 splitTerminalTabsById = useAppStore( + useShallow((s) => selectSplitTerminalTabsById(s.terminalLayoutsByTabId)) ) const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true) const nativeChatTranscriptIsLocalReadable = useAppStore((s) => @@ -1117,14 +1124,15 @@ 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 isSplitTerminalTab = splitTerminalTabsById[terminalTab.id] === true const canToggleViewMode = unifiedTabForItem !== undefined && canToggleNativeChat({ experimentalNativeChatEnabled: nativeChatEnabled, contentType: 'terminal', - launchAgent: terminalTab.launchAgent, + launchAgent: isSplitTerminalTab ? null : terminalTab.launchAgent, detectedAgent, - resolvedAgent, + resolvedAgent: isSplitTerminalTab ? null : resolvedAgent, 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..b425516b105 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 { + selectSplitTerminalTabsById, + 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,81 @@ 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(selectSplitTerminalTabsById(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() + }) + 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..2be461f0004 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,18 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' + +function layoutContainsLeaf(layout: TerminalLayoutSnapshot, leafId: string): boolean { + const visit = (node: TerminalLayoutSnapshot['root']): boolean => { + if (!node) { + return false + } + if (node.type === 'leaf') { + return node.leafId === leafId + } + return visit(node.first) || visit(node.second) + } + return layout.root === null || visit(layout.root) +} /** * Project `agentStatusByPaneKey` down to the stable `{ terminalTabId: agentType }` @@ -13,16 +27,30 @@ 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)) { + claimed.add(tabId) + const activeLeafId = + layout.activeLeafId ?? (layout.root?.type === 'leaf' ? layout.root.leafId : null) + if (!activeLeafId || !layoutContainsLeaf(layout, 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 +67,15 @@ export function selectTabAgentTypesByTabId( } return byTabId } + +export function selectSplitTerminalTabsById( + terminalLayoutsByTabId: Record +): Record { + const splitTabs: Record = {} + for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { + if (layout.root?.type === 'split') { + splitTabs[tabId] = true + } + } + return splitTabs +} 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={{