From ddd504a7fa3a214fdd38e83a05f812fcfccee130 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:45:18 -0700 Subject: [PATCH] fix(native-chat): allow zero-message agent sessions --- .../native-chat-pane-agent.test.ts | 81 +++++++++++++ .../native-chat/native-chat-pane-agent.ts | 106 ++++++++++++++++++ .../use-native-chat-toggle-shortcut.test.ts | 37 ++++++ .../use-native-chat-toggle-shortcut.ts | 43 +++++-- .../tab-bar/tab-agent-types-by-tab-id.test.ts | 21 ++++ .../tab-bar/tab-agent-types-by-tab-id.ts | 80 +++++++++++-- .../components/terminal-pane/TerminalPane.tsx | 79 ++++++------- .../native-chat-zero-message-toggle.spec.ts | 83 ++++++++++++++ 8 files changed, 470 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-pane-agent.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pane-agent.ts create mode 100644 tests/e2e/native-chat-zero-message-toggle.spec.ts diff --git a/src/renderer/src/components/native-chat/native-chat-pane-agent.test.ts b/src/renderer/src/components/native-chat/native-chat-pane-agent.test.ts new file mode 100644 index 00000000000..e82b1fb7538 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pane-agent.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + createTerminalTabNativeChatPaneEvidenceSelector, + resolveNativeChatPaneAgent +} from './native-chat-pane-agent' + +describe('resolveNativeChatPaneAgent', () => { + it('admits the pane launch identity before the first hook', () => { + expect(resolveNativeChatPaneAgent({ paneLaunchAgent: 'codex' })).toBe('codex') + }) + + it('prefers current hook and process identity over launch metadata', () => { + expect( + resolveNativeChatPaneAgent({ + hookAgent: 'claude', + foreground: { agent: 'codex', shellForeground: false }, + paneLaunchAgent: 'grok' + }) + ).toBe('claude') + expect( + resolveNativeChatPaneAgent({ + foreground: { agent: 'codex', shellForeground: false }, + paneLaunchAgent: 'grok' + }) + ).toBe('codex') + }) + + it('rejects stale launch metadata after process exit evidence', () => { + expect( + resolveNativeChatPaneAgent({ + foreground: { agent: null, shellForeground: true }, + paneLaunchAgent: 'codex' + }) + ).toBeNull() + expect( + resolveNativeChatPaneAgent({ + foreground: { agent: null, routingRevoked: true, shellForeground: false }, + paneLaunchAgent: 'codex' + }) + ).toBeNull() + }) +}) + +describe('createTerminalTabNativeChatPaneEvidenceSelector', () => { + it('keeps launch and foreground evidence scoped to each split leaf', () => { + const select = createTerminalTabNativeChatPaneEvidenceSelector() + const launchConfigs = { + 'tab-1:leaf-a': { identity: { agentType: 'codex' as const } }, + 'tab-1:leaf-b': { identity: { agentType: 'gemini' as const } }, + 'tab-2:leaf-c': { identity: { agentType: 'claude' as const } } + } + const foregroundAgents = { + 'tab-1:leaf-b': { agent: 'gemini' as const, shellForeground: false } + } + + expect(select(launchConfigs, foregroundAgents, 'tab-1')).toEqual({ + 'leaf-a': { paneLaunchAgent: 'codex' }, + 'leaf-b': { + paneLaunchAgent: 'gemini', + foreground: foregroundAgents['tab-1:leaf-b'] + } + }) + expect(select(launchConfigs, foregroundAgents, 'tab-2')).toEqual({ + 'leaf-c': { paneLaunchAgent: 'claude' } + }) + }) + + it('reuses per-tab projections when unrelated pane maps change', () => { + const select = createTerminalTabNativeChatPaneEvidenceSelector() + const firstLaunchConfigs = { + 'tab-1:leaf-a': { identity: { agentType: 'codex' as const } } + } + const first = select(firstLaunchConfigs, {}, 'tab-1') + const nextLaunchConfigs = { + ...firstLaunchConfigs, + 'tab-2:leaf-b': { identity: { agentType: 'claude' as const } } + } + + expect(select(nextLaunchConfigs, {}, 'tab-1')).toBe(first) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pane-agent.ts b/src/renderer/src/components/native-chat/native-chat-pane-agent.ts new file mode 100644 index 00000000000..9f127656d44 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pane-agent.ts @@ -0,0 +1,106 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +export type NativeChatPaneAgentInput = { + hookAgent?: AgentType | null + foreground?: PaneForegroundAgentEntry + paneLaunchAgent?: AgentType | null + fallbackAgent?: AgentType | null +} + +/** Resolve pane-local runtime evidence before the legacy single-pane fallback. */ +export function resolveNativeChatPaneAgent(input: NativeChatPaneAgentInput): AgentType | null { + if (input.hookAgent) { + return input.hookAgent + } + if (input.foreground?.shellForeground || input.foreground?.routingRevoked) { + return null + } + return input.foreground?.agent ?? input.paneLaunchAgent ?? input.fallbackAgent ?? null +} + +type LaunchConfigState = Record +type ForegroundState = Record + +export type NativeChatPaneEvidence = { + paneLaunchAgent?: AgentType | null + foreground?: PaneForegroundAgentEntry +} +export type NativeChatPaneEvidenceByLeaf = Readonly> + +const EMPTY_EVIDENCE_BY_LEAF: NativeChatPaneEvidenceByLeaf = Object.freeze({}) + +function recordsEqual( + previous: NativeChatPaneEvidenceByLeaf | undefined, + next: Record +): boolean { + if (!previous) { + return false + } + const nextKeys = Object.keys(next) + if (Object.keys(previous).length !== nextKeys.length) { + return false + } + return nextKeys.every((leafId) => { + const prior = previous[leafId] + const current = next[leafId] + return ( + prior?.paneLaunchAgent === current.paneLaunchAgent && prior?.foreground === current.foreground + ) + }) +} + +export function createTerminalTabNativeChatPaneEvidenceSelector(): ( + launchConfigs: LaunchConfigState, + foregroundAgents: ForegroundState, + tabId: string +) => NativeChatPaneEvidenceByLeaf { + let cachedLaunchConfigs: LaunchConfigState | null = null + let cachedForegroundAgents: ForegroundState | null = null + let cachedByTabId = new Map() + + return (launchConfigs, foregroundAgents, tabId) => { + if (launchConfigs !== cachedLaunchConfigs || foregroundAgents !== cachedForegroundAgents) { + const previousByTabId = cachedByTabId + const nextByTabId = new Map>() + const write = (paneKey: string, evidence: NativeChatPaneEvidence): void => { + const separator = paneKey.indexOf(':') + if (separator <= 0) { + return + } + const entryTabId = paneKey.slice(0, separator) + const leafId = paneKey.slice(separator + 1) + const byLeaf = nextByTabId.get(entryTabId) + if (byLeaf) { + byLeaf[leafId] = { ...byLeaf[leafId], ...evidence } + } else { + nextByTabId.set(entryTabId, { [leafId]: evidence }) + } + } + + for (const [paneKey, entry] of Object.entries(launchConfigs)) { + if (entry?.identity.agentType) { + write(paneKey, { paneLaunchAgent: entry.identity.agentType }) + } + } + for (const [paneKey, foreground] of Object.entries(foregroundAgents)) { + if (foreground) { + write(paneKey, { foreground }) + } + } + + const stabilized = new Map() + for (const [entryTabId, byLeaf] of nextByTabId) { + const previous = previousByTabId.get(entryTabId) + stabilized.set(entryTabId, recordsEqual(previous, byLeaf) ? previous! : byLeaf) + } + cachedByTabId = stabilized + cachedLaunchConfigs = launchConfigs + cachedForegroundAgents = foregroundAgents + } + return cachedByTabId.get(tabId) ?? EMPTY_EVIDENCE_BY_LEAF + } +} + +export const selectTerminalTabNativeChatPaneEvidence = + createTerminalTabNativeChatPaneEvidenceSelector() 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 d2a037da269..7548b1ee5c1 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 @@ -82,6 +82,43 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { ).toBe('codex') }) + it('uses pane launch identity before the first hook', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + }, + agentStatusByPaneKey: {}, + agentLaunchConfigByPaneKey: { + 'tab-1:leaf-1': { identity: { agentType: 'codex' } } + } + }) + ).toBe('codex') + }) + + it('rejects stale launch identity after shell confirmation', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + }, + agentStatusByPaneKey: {}, + agentLaunchConfigByPaneKey: { + 'tab-1:leaf-1': { identity: { agentType: 'codex' } } + }, + paneForegroundAgentByPaneKey: { + 'tab-1:leaf-1': { agent: null, shellForeground: true } + } + }) + ).toBeNull() + }) + it('rejects a stale active leaf instead of reading its retained status', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ 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 3096077e820..d759dc7fc5e 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 @@ -11,28 +11,47 @@ import { isNativeChatTabWideFallbackSafe, resolveNativeChatActiveLayoutLeafId } from './native-chat-leaf-routing' +import { resolveNativeChatPaneAgent } from './native-chat-pane-agent' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +type PaneLaunchAgentState = Record< + string, + { identity: { agentType?: AgentType | null } } | undefined +> export function resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId, terminalLayout, - agentStatusByPaneKey + agentStatusByPaneKey, + agentLaunchConfigByPaneKey = {}, + paneForegroundAgentByPaneKey = {} }: { terminalTabId: string terminalLayout?: TerminalLayoutSnapshot | null agentStatusByPaneKey: Record + agentLaunchConfigByPaneKey?: PaneLaunchAgentState + paneForegroundAgentByPaneKey?: Record }): AgentType | null { const activeLeafId = resolveNativeChatActiveLayoutLeafId(terminalLayout) - if (activeLeafId) { - return agentStatusByPaneKey[`${terminalTabId}:${activeLeafId}`]?.agentType ?? null - } - if (!isNativeChatTabWideFallbackSafe(terminalLayout)) { + if (!activeLeafId && !isNativeChatTabWideFallbackSafe(terminalLayout)) { return null } - return ( - Object.entries(agentStatusByPaneKey).find(([paneKey]) => - paneKey.startsWith(`${terminalTabId}:`) - )?.[1].agentType ?? null - ) + const prefix = `${terminalTabId}:` + const paneKey = activeLeafId + ? `${prefix}${activeLeafId}` + : ([ + ...Object.keys(agentStatusByPaneKey), + ...Object.keys(agentLaunchConfigByPaneKey), + ...Object.keys(paneForegroundAgentByPaneKey) + ].find((key) => key.startsWith(prefix)) ?? null) + if (!paneKey) { + return null + } + return resolveNativeChatPaneAgent({ + hookAgent: agentStatusByPaneKey[paneKey]?.agentType, + foreground: paneForegroundAgentByPaneKey[paneKey], + paneLaunchAgent: agentLaunchConfigByPaneKey[paneKey]?.identity.agentType + }) } /** Toggles the active worktree's focused agent-terminal tab between the terminal @@ -73,7 +92,9 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive const detectedAgent = resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: tab.entityId, terminalLayout, - agentStatusByPaneKey: state.agentStatusByPaneKey + agentStatusByPaneKey: state.agentStatusByPaneKey, + agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey, + paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey }) const titleFallbackAgent = tabWideFallbackSafe ? (resolveCommittedTitleAgentType(tab.label ?? '') ?? 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 e460318e609..46e4a016ab2 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 @@ -71,6 +71,27 @@ describe('selectTabAgentTypesByTabId', () => { expect(selectNativeChatTabWideFallbackUnsafeTabsById(layouts)).toEqual({ 'tab-1': true }) }) + it('projects pane launch identity before the active leaf has a hook', () => { + expect( + selectTabAgentTypesByTabId( + {}, + { 'tab-1': splitLayout('leaf-b') }, + { 'tab-1:leaf-b': { identity: { agentType: 'codex' } } } + ) + ).toEqual({ 'tab-1': 'codex' }) + }) + + it('drops projected launch identity after active-leaf shell confirmation', () => { + expect( + selectTabAgentTypesByTabId( + {}, + { 'tab-1': splitLayout('leaf-b') }, + { 'tab-1:leaf-b': { identity: { agentType: 'codex' } } }, + { 'tab-1:leaf-b': { agent: null, shellForeground: true } } + ) + ).toEqual({}) + }) + it('does not inherit a supported sibling when the active split leaf is a shell', () => { const projection = selectTabAgentTypesByTabId( { 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 b320d5e0ddc..b4ae95277c6 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 @@ -4,6 +4,13 @@ import { isNativeChatTabWideFallbackSafe, resolveNativeChatActiveLayoutLeafId } from '../native-chat/native-chat-leaf-routing' +import { resolveNativeChatPaneAgent } from '../native-chat/native-chat-pane-agent' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +type PaneLaunchAgentState = Record< + string, + { identity: { agentType?: AgentType | null } } | undefined +> type TabBarAgentProjectionSelectorDependencies = { onStatusEntryVisited?: (paneKey: string) => void @@ -13,6 +20,8 @@ type TabBarAgentProjectionSelectorDependencies = { export type TabBarAgentProjectionState = { agentStatusByPaneKey?: Record + agentLaunchConfigByPaneKey?: PaneLaunchAgentState + paneForegroundAgentByPaneKey?: Record terminalLayoutsByTabId?: Record settings?: { experimentalNativeChat?: boolean } | null } @@ -24,6 +33,10 @@ export type TabBarAgentProjections = { } const EMPTY_AGENT_STATUS_BY_PANE_KEY: Record = Object.freeze({}) +const EMPTY_PANE_LAUNCH_AGENT_STATE: PaneLaunchAgentState = Object.freeze({}) +const EMPTY_PANE_FOREGROUND_AGENT_STATE: Record = Object.freeze( + {} +) const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: Record = Object.freeze({}) const EMPTY_TAB_AGENT_TYPES_BY_TAB_ID: Record = Object.freeze({}) const EMPTY_UNSAFE_TABS_BY_ID: Record = Object.freeze({}) @@ -49,6 +62,8 @@ function reuseRecordIfEqual( function projectTabAgentTypesByTabId( agentStatusByPaneKey: Record, + agentLaunchConfigByPaneKey: PaneLaunchAgentState, + paneForegroundAgentByPaneKey: Record, terminalLayoutsByTabId: Record, dependencies?: TabBarAgentProjectionSelectorDependencies ): Record { @@ -64,13 +79,25 @@ function projectTabAgentTypesByTabId( if (!activeLeafId) { continue } - const entry = agentStatusByPaneKey[`${tabId}:${activeLeafId}`] - if (entry?.agentType != null) { - byTabId[tabId] = entry.agentType + const paneKey = `${tabId}:${activeLeafId}` + const agent = resolveNativeChatPaneAgent({ + hookAgent: agentStatusByPaneKey[paneKey]?.agentType, + foreground: paneForegroundAgentByPaneKey[paneKey], + paneLaunchAgent: agentLaunchConfigByPaneKey[paneKey]?.identity.agentType + }) + if (agent) { + byTabId[tabId] = agent } } - for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - dependencies?.onStatusEntryVisited?.(paneKey) + const paneKeys = new Set([ + ...Object.keys(agentStatusByPaneKey), + ...Object.keys(agentLaunchConfigByPaneKey), + ...Object.keys(paneForegroundAgentByPaneKey) + ]) + for (const paneKey of paneKeys) { + if (paneKey in agentStatusByPaneKey) { + dependencies?.onStatusEntryVisited?.(paneKey) + } const colon = paneKey.indexOf(':') if (colon <= 0) { continue @@ -80,8 +107,13 @@ function projectTabAgentTypesByTabId( continue } claimed.add(tabId) - if (entry.agentType != null) { - byTabId[tabId] = entry.agentType + const agent = resolveNativeChatPaneAgent({ + hookAgent: agentStatusByPaneKey[paneKey]?.agentType, + foreground: paneForegroundAgentByPaneKey[paneKey], + paneLaunchAgent: agentLaunchConfigByPaneKey[paneKey]?.identity.agentType + }) + if (agent) { + byTabId[tabId] = agent } } return byTabId @@ -120,9 +152,16 @@ function projectNativeChatTabWideFallbackUnsafeTabsById( */ export function selectTabAgentTypesByTabId( agentStatusByPaneKey: Record, - terminalLayoutsByTabId: Record = {} + terminalLayoutsByTabId: Record = {}, + agentLaunchConfigByPaneKey: PaneLaunchAgentState = {}, + paneForegroundAgentByPaneKey: Record = {} ): Record { - return projectTabAgentTypesByTabId(agentStatusByPaneKey, terminalLayoutsByTabId) + return projectTabAgentTypesByTabId( + agentStatusByPaneKey, + agentLaunchConfigByPaneKey, + paneForegroundAgentByPaneKey, + terminalLayoutsByTabId + ) } export function selectNativeChatTabWideFallbackUnsafeTabsById( @@ -135,6 +174,8 @@ export function createTabBarAgentProjectionSelector( dependencies?: TabBarAgentProjectionSelectorDependencies ): (state: TabBarAgentProjectionState) => TabBarAgentProjections { let cachedAgentStatusByPaneKey: Record | null = null + let cachedAgentLaunchConfigByPaneKey: PaneLaunchAgentState | null = null + let cachedPaneForegroundAgentByPaneKey: Record | null = null let cachedAgentTypeLayoutsByTabId: Record | null = null let cachedAgentTypesByTabId = EMPTY_TAB_AGENT_TYPES_BY_TAB_ID let cachedUnsafeLayoutsByTabId: Record | null = null @@ -145,6 +186,8 @@ export function createTabBarAgentProjectionSelector( if (state.settings?.experimentalNativeChat !== true) { if (cachedEnabledResult) { cachedAgentStatusByPaneKey = null + cachedAgentLaunchConfigByPaneKey = null + cachedPaneForegroundAgentByPaneKey = null cachedAgentTypeLayoutsByTabId = null cachedAgentTypesByTabId = EMPTY_TAB_AGENT_TYPES_BY_TAB_ID cachedUnsafeLayoutsByTabId = null @@ -155,13 +198,28 @@ export function createTabBarAgentProjectionSelector( } const statuses = state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY + const launchConfigs = state.agentLaunchConfigByPaneKey ?? EMPTY_PANE_LAUNCH_AGENT_STATE + const foregroundAgents = state.paneForegroundAgentByPaneKey ?? EMPTY_PANE_FOREGROUND_AGENT_STATE const layouts = state.terminalLayoutsByTabId ?? EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID - if (statuses !== cachedAgentStatusByPaneKey || layouts !== cachedAgentTypeLayoutsByTabId) { + if ( + statuses !== cachedAgentStatusByPaneKey || + launchConfigs !== cachedAgentLaunchConfigByPaneKey || + foregroundAgents !== cachedPaneForegroundAgentByPaneKey || + layouts !== cachedAgentTypeLayoutsByTabId + ) { cachedAgentTypesByTabId = reuseRecordIfEqual( cachedAgentTypesByTabId, - projectTabAgentTypesByTabId(statuses, layouts, dependencies) + projectTabAgentTypesByTabId( + statuses, + launchConfigs, + foregroundAgents, + layouts, + dependencies + ) ) cachedAgentStatusByPaneKey = statuses + cachedAgentLaunchConfigByPaneKey = launchConfigs + cachedPaneForegroundAgentByPaneKey = foregroundAgents cachedAgentTypeLayoutsByTabId = layouts } if (layouts !== cachedUnsafeLayoutsByTabId) { diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 323a3dc497e..edfba6c6345 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -171,6 +171,7 @@ import { isTerminalQuickCommandComplete } from '../../../../shared/terminal-quick-commands' import { terminalQuickCommandMatchesWorkspaceProject } from '@/lib/terminal-quick-command-project-scope' +import { isTuiAgent } from '../../../../shared/tui-agent-config' import { createTerminalQuickCommandDraft, TerminalQuickCommandDialog @@ -192,6 +193,10 @@ import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim' import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay' import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner' import { selectTerminalTabAgentTypesByLeaf } from './terminal-tab-agent-type-index' +import { + resolveNativeChatPaneAgent, + selectTerminalTabNativeChatPaneEvidence +} from '../native-chat/native-chat-pane-agent' import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation' import { updateTerminalRemoteRuntimeRecoveryUiState, @@ -522,6 +527,13 @@ function TerminalPane( const tabAgentTypeByLeaf = useAppStore((store) => selectTerminalTabAgentTypesByLeaf(store.agentStatusByPaneKey, tabId) ) + const nativeChatPaneEvidenceByLeaf = useAppStore((store) => + selectTerminalTabNativeChatPaneEvidence( + store.agentLaunchConfigByPaneKey, + store.paneForegroundAgentByPaneKey, + tabId + ) + ) const toggleTabViewMode = useAppStore((store) => store.toggleTabViewMode) const setTabViewMode = useAppStore((store) => store.setTabViewMode) const savedLayout = useAppStore((store) => store.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT) @@ -579,35 +591,43 @@ function TerminalPane( unifiedTabLabel ] ) - // Per-leaf: a split can mix supported/unsupported agents; the leaf's live agent is authoritative, tab-wide hints only fill in before hooks arrive. - const isChatEligibleForLeaf = useCallback( - (leafId: string | null): boolean => { - const detectedAgent = leafId ? (tabAgentTypeByLeaf[leafId] ?? null) : null - const launchAgent = nativeChatLaunchAgentForLeaf({ + const resolveNativeChatAgentForLeaf = useCallback( + (leafId: string | null) => { + const tabWideLaunchAgent = nativeChatLaunchAgentForLeaf({ launchAgent: terminalTab?.launchAgent, launchAgentLeafId: getTabWideAgentHintLeafId(), leafId, leafIds: getNativeChatLeafIds() }) - return canToggleNativeChat({ - experimentalNativeChatEnabled: nativeChatEnabled, - contentType: 'terminal', - launchAgent: detectedAgent ? null : launchAgent, - detectedAgent, - resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId), - nativeChatTranscriptIsLocalReadable + const paneEvidence = leafId ? nativeChatPaneEvidenceByLeaf[leafId] : undefined + return resolveNativeChatPaneAgent({ + hookAgent: leafId ? (tabAgentTypeByLeaf[leafId] ?? null) : null, + foreground: paneEvidence?.foreground, + paneLaunchAgent: paneEvidence?.paneLaunchAgent, + fallbackAgent: tabWideLaunchAgent ?? resolveTitleAgentForLeaf(leafId) }) }, [ - tabAgentTypeByLeaf, - nativeChatEnabled, - nativeChatTranscriptIsLocalReadable, - terminalTab?.launchAgent, getNativeChatLeafIds, getTabWideAgentHintLeafId, - resolveTitleAgentForLeaf + nativeChatPaneEvidenceByLeaf, + resolveTitleAgentForLeaf, + tabAgentTypeByLeaf, + terminalTab?.launchAgent ] ) + // Per-leaf: a split can mix supported/unsupported agents; the leaf's live agent is authoritative, tab-wide hints only fill in before hooks arrive. + const isChatEligibleForLeaf = useCallback( + (leafId: string | null): boolean => { + return canToggleNativeChat({ + experimentalNativeChatEnabled: nativeChatEnabled, + contentType: 'terminal', + detectedAgent: resolveNativeChatAgentForLeaf(leafId), + nativeChatTranscriptIsLocalReadable + }) + }, + [nativeChatEnabled, nativeChatTranscriptIsLocalReadable, resolveNativeChatAgentForLeaf] + ) const applyNativeChatLeafRoute = useCallback( (route: NativeChatLeafRoute): void => { if (route.chatLeafId !== chatLeafId) { @@ -2883,30 +2903,14 @@ function TerminalPane( const chatPanePtyId = chatPane ? (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 chatPaneAgentCandidate = chatPane ? resolveNativeChatAgentForLeaf(chatPane.leafId) : null + const chatPaneAgent = isTuiAgent(chatPaneAgentCandidate) ? chatPaneAgentCandidate : null const activePaneIsChatLeaf = Boolean( isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId ) // A split can host different agents, so continuation resolves the specific leaf before using tab-wide hints. const resolveAgentForLeaf = (leafId: string | null): string | null => { - const detectedAgent = leafId ? (tabAgentTypeByLeaf[leafId] ?? null) : null - if (detectedAgent) { - return detectedAgent - } - return ( - nativeChatLaunchAgentForLeaf({ - launchAgent: terminalTab?.launchAgent, - launchAgentLeafId: getTabWideAgentHintLeafId(), - leafId, - leafIds: getNativeChatLeafIds() - }) ?? resolveTitleAgentForLeaf(leafId) - ) + return resolveNativeChatAgentForLeaf(leafId) } const activePaneCanContinueInNewSession = canContinueAgentSessionInNewSession( resolveAgentForLeaf(activePane?.leafId ?? null) @@ -3037,8 +3041,7 @@ function TerminalPane( isVisible={isRendererVisible} paneKey={makePaneKey(tabId, chatPane.leafId)} targetPtyId={chatPanePtyId} - launchAgent={chatPaneLaunchAgent} - resolvedAgent={chatPaneResolvedAgent} + launchAgent={chatPaneAgent} onSwitchToTerminal={switchNativeChatToTerminal} readTerminalScreen={readNativeChatTerminalScreen} contextMenuActions={{ diff --git a/tests/e2e/native-chat-zero-message-toggle.spec.ts b/tests/e2e/native-chat-zero-message-toggle.spec.ts new file mode 100644 index 00000000000..9039b8d4e09 --- /dev/null +++ b/tests/e2e/native-chat-zero-message-toggle.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import type { GlobalSettings } from '../../src/shared/types' + +async function enableNativeChat(page: Parameters[0]): Promise { + await page.evaluate(async () => { + const settings = await window.api.settings.set({ experimentalNativeChat: true }) + window.__store?.setState({ settings: settings as GlobalSettings }) + }) +} + +test.describe('Native chat zero-message admission (STA-3982)', () => { + test('opens Chat UI from pane launch identity before the first agent hook', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await enableNativeChat(orcaPage) + + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const [tabId, leafId] = descriptor.paneKey.split(':') + if (!tabId || !leafId) { + throw new Error(`Invalid pane key: ${descriptor.paneKey}`) + } + + await orcaPage.evaluate( + ({ leafId, paneKey, tabId }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + state.dropAgentStatus(paneKey) + state.registerAgentLaunchConfig( + paneKey, + { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + { agentType: 'codex', tabId, leafId } + ) + }, + { leafId, paneKey: descriptor.paneKey, tabId } + ) + + await expect + .poll(() => + orcaPage.evaluate((paneKey) => { + const state = window.__store?.getState() + return { + agentStatus: state?.agentStatusByPaneKey[paneKey] ?? null, + launchAgent: state?.agentLaunchConfigByPaneKey[paneKey]?.identity.agentType ?? null + } + }, descriptor.paneKey) + ) + .toEqual({ agentStatus: null, launchAgent: 'codex' }) + + await orcaPage.screenshot({ path: testInfo.outputPath('01-zero-message-terminal.png') }) + const showChat = orcaPage.getByRole('button', { name: 'Show chat view' }) + await expect(showChat).toBeVisible() + await showChat.click() + + await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible() + await expect(orcaPage.getByText('Start a chat with Codex')).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('02-zero-message-chat.png') }) + + await orcaPage.getByRole('button', { name: 'Show terminal' }).click() + await expect(orcaPage.locator('[data-native-chat-root="true"]')).toHaveCount(0) + await orcaPage.evaluate((paneKey) => { + window.__store?.getState().setPaneForegroundAgent(paneKey, { + agent: null, + shellForeground: true + }) + }, descriptor.paneKey) + await expect(showChat).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('03-confirmed-shell-no-chat.png') }) + + await orcaPage.evaluate((paneKey) => { + window.__store?.getState().clearPaneForegroundAgent(paneKey) + }, descriptor.paneKey) + await expect(showChat).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('04-restored-launch-identity.png') }) + }) +})