fix(native-chat): allow zero-message agent sessions

This commit is contained in:
Brennan Benson
2026-08-12 21:45:30 -07:00
parent de729d6067
commit ddd504a7fa
8 changed files with 470 additions and 60 deletions
@@ -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)
})
})
@@ -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<string, { identity: { agentType?: AgentType | null } } | undefined>
type ForegroundState = Record<string, PaneForegroundAgentEntry | undefined>
export type NativeChatPaneEvidence = {
paneLaunchAgent?: AgentType | null
foreground?: PaneForegroundAgentEntry
}
export type NativeChatPaneEvidenceByLeaf = Readonly<Record<string, NativeChatPaneEvidence>>
const EMPTY_EVIDENCE_BY_LEAF: NativeChatPaneEvidenceByLeaf = Object.freeze({})
function recordsEqual(
previous: NativeChatPaneEvidenceByLeaf | undefined,
next: Record<string, NativeChatPaneEvidence>
): 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<string, NativeChatPaneEvidenceByLeaf>()
return (launchConfigs, foregroundAgents, tabId) => {
if (launchConfigs !== cachedLaunchConfigs || foregroundAgents !== cachedForegroundAgents) {
const previousByTabId = cachedByTabId
const nextByTabId = new Map<string, Record<string, NativeChatPaneEvidence>>()
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<string, NativeChatPaneEvidenceByLeaf>()
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()
@@ -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({
@@ -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<string, { agentType?: AgentType }>
agentLaunchConfigByPaneKey?: PaneLaunchAgentState
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry | undefined>
}): 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 ?? '') ??
@@ -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(
{
@@ -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<string, AgentStatusEntry>
agentLaunchConfigByPaneKey?: PaneLaunchAgentState
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
settings?: { experimentalNativeChat?: boolean } | null
}
@@ -24,6 +33,10 @@ export type TabBarAgentProjections = {
}
const EMPTY_AGENT_STATUS_BY_PANE_KEY: Record<string, AgentStatusEntry> = Object.freeze({})
const EMPTY_PANE_LAUNCH_AGENT_STATE: PaneLaunchAgentState = Object.freeze({})
const EMPTY_PANE_FOREGROUND_AGENT_STATE: Record<string, PaneForegroundAgentEntry> = Object.freeze(
{}
)
const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: Record<string, TerminalLayoutSnapshot> = Object.freeze({})
const EMPTY_TAB_AGENT_TYPES_BY_TAB_ID: Record<string, AgentType> = Object.freeze({})
const EMPTY_UNSAFE_TABS_BY_ID: Record<string, true> = Object.freeze({})
@@ -49,6 +62,8 @@ function reuseRecordIfEqual<T>(
function projectTabAgentTypesByTabId(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
agentLaunchConfigByPaneKey: PaneLaunchAgentState,
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>,
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>,
dependencies?: TabBarAgentProjectionSelectorDependencies
): Record<string, AgentType> {
@@ -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<string, AgentStatusEntry>,
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {},
agentLaunchConfigByPaneKey: PaneLaunchAgentState = {},
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry> = {}
): Record<string, AgentType> {
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<string, AgentStatusEntry> | null = null
let cachedAgentLaunchConfigByPaneKey: PaneLaunchAgentState | null = null
let cachedPaneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry> | null = null
let cachedAgentTypeLayoutsByTabId: Record<string, TerminalLayoutSnapshot> | null = null
let cachedAgentTypesByTabId = EMPTY_TAB_AGENT_TYPES_BY_TAB_ID
let cachedUnsafeLayoutsByTabId: Record<string, TerminalLayoutSnapshot> | 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) {
@@ -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={{
@@ -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<typeof waitForSessionReady>[0]): Promise<void> {
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') })
})
})