Fix Windows native chat title fallback (#7313)

* fix: resolve title-only native chat agents

* test: cover native chat unsupported identity fallback

* fix: harden native chat agent fallback precedence

---------

Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
This commit is contained in:
slashdevcorpse 2026-07-04 02:02:12 -04:00 committed by GitHub
parent 19bb7df93a
commit 9fa1313179
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 560 additions and 63 deletions

View File

@ -0,0 +1,24 @@
import type * as React from 'react'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import {
resolveNativeChatSession,
type NativeChatPaneResolution,
type NativeChatPaneResolutionInput
} from './native-chat-pane-resolution'
export type NativeChatSessionGateProps = NativeChatPaneResolutionInput & {
children: (resolution: NativeChatPaneResolution) => React.ReactNode
}
/** Keeps NativeChatView's agent/session resolution separate from the heavy
* conversation surface so unsupported panes fail before transcript IO starts. */
export function NativeChatSessionGate({
children,
...input
}: NativeChatSessionGateProps): React.JSX.Element {
const resolution = resolveNativeChatSession(input)
if (!resolution) {
return <NativeChatEmptyState kind="not-agent" />
}
return <>{children(resolution)}</>
}

View File

@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import type * as React from 'react'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { NativeChatSessionGate } from './NativeChatSessionGate'
function entry(overrides: Partial<AgentStatusEntry> & Pick<AgentStatusEntry, 'paneKey'>) {
return {
state: 'working' as const,
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
stateHistory: [],
...overrides
}
}
function renderResolution(
props: Omit<React.ComponentProps<typeof NativeChatSessionGate>, 'children'>
): void {
render(
<NativeChatSessionGate {...props}>
{(resolution) => (
<div data-testid="native-chat-resolution">
{resolution.agent}:{resolution.sessionId ?? 'no-session'}:{resolution.paneKey}
</div>
)}
</NativeChatSessionGate>
)
}
describe('NativeChatSessionGate', () => {
afterEach(() => {
cleanup()
})
it.each(['codex', 'claude'] as const)(
'opens the resolved native chat session from a %s title fallback',
(resolvedAgent) => {
renderResolution({
paneKey: 'tab-1:leaf-1',
launchAgent: null,
resolvedAgent,
ptyId: 'pty-1'
})
expect(screen.getByTestId('native-chat-resolution')).toHaveTextContent(
`${resolvedAgent}:no-session:tab-1:leaf-1`
)
}
)
it('keeps live hook identity ahead of a stale title fallback', () => {
renderResolution({
paneKey: 'tab-1:leaf-1',
launchAgent: null,
agentStatusEntry: entry({
paneKey: 'tab-1:leaf-1',
agentType: 'claude',
providerSession: { key: 'session_id', id: 'claude-session' }
}),
resolvedAgent: 'codex',
ptyId: 'pty-1'
})
expect(screen.getByTestId('native-chat-resolution')).toHaveTextContent(
'claude:claude-session:tab-1:leaf-1'
)
})
it('does not open native chat from an unsupported title fallback', () => {
renderResolution({
paneKey: 'tab-1:leaf-1',
launchAgent: null,
resolvedAgent: 'grok',
ptyId: 'pty-1'
})
expect(screen.getByText('No conversation here')).toBeInTheDocument()
expect(screen.queryByTestId('native-chat-resolution')).not.toBeInTheDocument()
})
})

View File

@ -4,7 +4,6 @@ import { useAppStore } from '../../store'
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
import type { TuiAgent } from '../../../../shared/types'
import type { NativeChatSession } from '../../../../shared/native-chat-types'
import { resolveNativeChatSession } from './native-chat-pane-resolution'
import { useNativeChatLiveSession } from './use-native-chat-live-session'
import { selectNativeChatViewState } from './native-chat-view-state'
import { NativeChatMessageList } from './NativeChatMessageList'
@ -13,6 +12,7 @@ import { useNativeChatFontScale } from './use-native-chat-font-scale'
import { useNativeChatCanSend } from './use-native-chat-can-send'
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import { NativeChatSessionGate } from './NativeChatSessionGate'
import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
import { findTabAgentEntry } from './native-chat-tab-agent-entry'
import {
@ -47,8 +47,8 @@ import {
resolveNativeChatFileLink,
resolveNativeChatFileLinkContext
} from './native-chat-file-link'
import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing'
const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuActions, 'onPaste'> = {
onSplitRight: () => {},
@ -75,6 +75,8 @@ export type NativeChatViewProps = {
targetPtyId?: string | null
/** Launch-time agent hint from the TerminalTab, when Orca started one. */
launchAgent?: TuiAgent | null
/** Trusted title/foreground fallback for manually-started agents. */
resolvedAgent?: TuiAgent | null
/** Return this pane to the hosted terminal surface. */
onSwitchToTerminal?: () => void
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
@ -93,6 +95,7 @@ export default function NativeChatView({
paneKey: preferredPaneKey,
targetPtyId = null,
launchAgent,
resolvedAgent,
onSwitchToTerminal,
contextMenuActions
}: NativeChatViewProps): React.JSX.Element {
@ -106,33 +109,30 @@ export default function NativeChatView({
)
)
const resolution = useMemo(() => {
// paneKey: prefer the live entry's key; fall back to the tab id so the hook
// still has a stable key to select live status by before any pane reports.
const paneKey = preferredPaneKey ?? agentStatusEntry?.paneKey ?? `${terminalTabId}:`
return resolveNativeChatSession({
paneKey,
launchAgent,
...(agentStatusEntry ? { agentStatusEntry } : {}),
ptyId: targetPtyId
})
}, [agentStatusEntry, terminalTabId, preferredPaneKey, targetPtyId, launchAgent])
if (!resolution) {
return <NativeChatEmptyState kind="not-agent" />
}
// paneKey: prefer the live entry's key; fall back to the tab id so the hook
// still has a stable key to select live status by before any pane reports.
const paneKey = preferredPaneKey ?? agentStatusEntry?.paneKey ?? `${terminalTabId}:`
return (
<NativeChatResolvedView
paneKey={resolution.paneKey}
agent={resolution.agent}
sessionId={resolution.sessionId}
transcriptPath={resolution.transcriptPath}
targetPtyId={targetPtyId}
terminalTabId={terminalTabId}
onSwitchToTerminal={onSwitchToTerminal}
contextMenuActions={contextMenuActions}
/>
<NativeChatSessionGate
paneKey={paneKey}
launchAgent={launchAgent}
resolvedAgent={resolvedAgent}
agentStatusEntry={agentStatusEntry}
ptyId={targetPtyId}
>
{(resolution) => (
<NativeChatResolvedView
paneKey={resolution.paneKey}
agent={resolution.agent}
sessionId={resolution.sessionId}
transcriptPath={resolution.transcriptPath}
targetPtyId={targetPtyId}
terminalTabId={terminalTabId}
onSwitchToTerminal={onSwitchToTerminal}
contextMenuActions={contextMenuActions}
/>
)}
</NativeChatSessionGate>
)
}

View File

@ -87,6 +87,40 @@ describe('canToggleNativeChat', () => {
).toBe(false)
})
it('rejects a stale supported title when live detection found an unsupported agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
detectedAgent: 'gemini',
resolvedAgent: 'codex'
})
).toBe(false)
})
it('rejects stale launch metadata when live detection found an unsupported agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: 'codex',
detectedAgent: 'gemini'
})
).toBe(false)
})
it('rejects a stale supported title when launch metadata names an unsupported agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: 'grok',
resolvedAgent: 'claude'
})
).toBe(false)
})
it('rejects otherwise eligible terminals while the experimental flag is off', () => {
expect(
canToggleNativeChat({

View File

@ -47,10 +47,9 @@ export type NativeChatAvailabilityInput = {
/** Native chat is a rendering of a coding-agent conversation, so the toggle is
* only meaningful on terminals that actually run an agent we can parse. Plain
* shells, non-terminal surfaces (editor, browser, ), and unsupported agents
* (Grok, Gemini, ) never qualify. Eligibility is the union of the launch-time
* hint, live detection, and title resolution but only when that signal names
* a supported agent so the control appears for both Orca-launched and
* user-started Claude/Codex sessions. */
* (Grok, Gemini, ) never qualify. Live identity is authoritative when present;
* launch metadata is next, and title resolution only fills the pre-hook gap for
* manually-started Claude/Codex sessions. */
export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean {
if (input.experimentalNativeChatEnabled !== true) {
return false
@ -58,10 +57,9 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean
if (input.contentType !== 'terminal') {
return false
}
return (
input.isChatViewMode === true ||
isNativeChatSupportedAgent(input.launchAgent) ||
isNativeChatSupportedAgent(input.detectedAgent) ||
isNativeChatSupportedAgent(input.resolvedAgent)
)
if (input.isChatViewMode === true) {
return true
}
const agent = input.detectedAgent ?? input.launchAgent ?? input.resolvedAgent
return isNativeChatSupportedAgent(agent)
}

View File

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TuiAgent } from '../../../../shared/types'
import { resolveNativeChatSession } from './native-chat-pane-resolution'
function entry(
@ -116,7 +117,64 @@ describe('resolveNativeChatSession', () => {
})
})
it('derives the agent from the status entry when no launchAgent is set', () => {
it('derives a supported agent from the status entry when no launchAgent is set', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: null,
agentStatusEntry: entry({
paneKey,
agentType: 'codex',
providerSession: { key: 'session_id', id: 'codex-1' }
}),
ptyId: 'pty-1'
})
).toEqual({
agent: 'codex',
sessionId: 'codex-1',
transcriptPath: null,
ptyId: 'pty-1',
paneKey
})
})
it.each(['codex', 'claude', 'openclaude'] as TuiAgent[])(
'resolves supported title fallback %s when no hook or launch identity exists',
(resolvedAgent) => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: null,
resolvedAgent,
ptyId: 'pty-1'
})
).toEqual({
agent: resolvedAgent,
sessionId: null,
transcriptPath: null,
ptyId: 'pty-1',
paneKey
})
}
)
it.each(['gemini', 'grok'] as TuiAgent[])(
'does not resolve unsupported title fallback %s',
(resolvedAgent) => {
expect(
resolveNativeChatSession({
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
launchAgent: null,
resolvedAgent,
ptyId: 'pty-1'
})
).toBeNull()
}
)
it('does not resolve an unsupported live status entry', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
@ -129,7 +187,97 @@ describe('resolveNativeChatSession', () => {
}),
ptyId: 'pty-1'
})
).toEqual({ agent: 'gemini', sessionId: 'g-1', transcriptPath: null, ptyId: 'pty-1', paneKey })
).toBeNull()
})
it('does not fall back to a supported title agent when live status is unsupported', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: null,
agentStatusEntry: entry({
paneKey,
agentType: 'gemini',
providerSession: { key: 'session_id', id: 'g-1' }
}),
resolvedAgent: 'codex',
ptyId: 'pty-1'
})
).toBeNull()
})
it('does not fall back to a supported launch agent when live status is unsupported', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: 'codex',
agentStatusEntry: entry({
paneKey,
agentType: 'gemini',
providerSession: { key: 'session_id', id: 'g-1' }
}),
resolvedAgent: 'claude',
ptyId: 'pty-1'
})
).toBeNull()
})
it('does not resolve an unsupported launch agent', () => {
expect(
resolveNativeChatSession({
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
launchAgent: 'grok',
ptyId: 'pty-1'
})
).toBeNull()
})
it('does not fall back to a supported title agent when launchAgent is unsupported', () => {
expect(
resolveNativeChatSession({
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
launchAgent: 'grok',
resolvedAgent: 'codex',
ptyId: 'pty-1'
})
).toBeNull()
})
it('keeps launch identity ahead of the title fallback', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: 'claude',
resolvedAgent: 'codex',
ptyId: 'pty-1'
})?.agent
).toBe('claude')
})
it('keeps live hook identity and provider session ahead of the title fallback', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: 'claude',
agentStatusEntry: entry({
paneKey,
agentType: 'codex',
providerSession: { key: 'session_id', id: 'codex-live' }
}),
resolvedAgent: 'claude',
ptyId: 'pty-1'
})
).toEqual({
agent: 'codex',
sessionId: 'codex-live',
transcriptPath: null,
ptyId: 'pty-1',
paneKey
})
})
it('returns null for a non-agent pane (no launchAgent, no entry)', () => {

View File

@ -1,5 +1,6 @@
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { TuiAgent } from '../../../../shared/types'
import { isNativeChatSupportedAgent } from './native-chat-availability'
/** Inputs that resolve the active pane to the agent/session/pty triple the
* native-chat data + input layers need. Kept as a plain shape (not the live
@ -19,6 +20,9 @@ export type NativeChatPaneResolutionInput = {
/** Runtime PTY id bound to this pane. ptyId is pane-manager runtime state, so
* it's passed in rather than looked up inside this pure function. */
ptyId: string | null
/** Agent identity resolved from trusted terminal title/foreground signals.
* Fallback only: launch metadata and hook status remain authoritative. */
resolvedAgent?: TuiAgent | null
}
export type NativeChatPaneResolution = {
@ -34,16 +38,16 @@ export type NativeChatPaneResolution = {
}
/** Resolve the active pane to `{ agent, sessionId, ptyId, paneKey }`, or null
* when the pane runs no agent. A pane qualifies when either a launch-time
* agent hint or a live agent-status entry is present (mirrors the eligibility
* union in native-chat-availability). sessionId comes from the entry's
* `providerSession.id` (the captured agent session id) null until the agent
* reports one, so a just-launched pane resolves without throwing. */
* when the pane runs no agent. A pane qualifies when a live agent-status entry,
* launch-time hint, or the same title-derived fallback used by the toggle is
* present. sessionId comes from the entry's `providerSession.id` (the captured
* agent session id) null until the agent reports one, so a just-launched
* pane resolves without throwing. */
export function resolveNativeChatSession(
input: NativeChatPaneResolutionInput
): NativeChatPaneResolution | null {
const agent = input.launchAgent ?? input.agentStatusEntry?.agentType
if (!agent) {
const agent = input.agentStatusEntry?.agentType ?? input.launchAgent ?? input.resolvedAgent
if (!agent || !isNativeChatSupportedAgent(agent)) {
return null
}
return {

View File

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
isNativeChatShortcutTitleFallbackSafe,
resolveNativeChatToggleShortcutDetectedAgent
} from './use-native-chat-toggle-shortcut'
describe('resolveNativeChatToggleShortcutDetectedAgent', () => {
it('uses the active split leaf instead of the first tab agent entry', () => {
expect(
resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: 'tab-1',
activeLeafId: 'leaf-2',
agentStatusByPaneKey: {
'tab-1:leaf-1': { agentType: 'gemini' },
'tab-1:leaf-2': { agentType: 'codex' }
}
})
).toBe('codex')
})
it('keeps an unsupported active leaf authoritative over a supported sibling', () => {
expect(
resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: 'tab-1',
activeLeafId: 'leaf-2',
agentStatusByPaneKey: {
'tab-1:leaf-1': { agentType: 'claude' },
'tab-1:leaf-2': { agentType: 'grok' }
}
})
).toBe('grok')
})
it('falls back to the tab entry before a leaf is known', () => {
expect(
resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: 'tab-1',
activeLeafId: null,
agentStatusByPaneKey: {
'tab-2:leaf-1': { agentType: 'codex' },
'tab-1:leaf-1': { agentType: 'claude' }
}
})
).toBe('claude')
})
})
describe('isNativeChatShortcutTitleFallbackSafe', () => {
it('allows title fallback before a layout snapshot exists', () => {
expect(isNativeChatShortcutTitleFallbackSafe(null)).toBe(true)
})
it('allows title fallback for a single leaf layout', () => {
expect(isNativeChatShortcutTitleFallbackSafe({ type: 'leaf', leafId: 'leaf-1' })).toBe(true)
})
it('rejects title fallback for split layouts', () => {
expect(
isNativeChatShortcutTitleFallbackSafe({
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-1' },
second: { type: 'leaf', leafId: 'leaf-2' }
})
).toBe(false)
})
})

View File

@ -1,9 +1,36 @@
import { useEffect } from 'react'
import { useAppStore } from '../../store'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { TerminalPaneLayoutNode } from '../../../../shared/types'
import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent'
import { canToggleNativeChat } from './native-chat-availability'
import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut'
export function isNativeChatShortcutTitleFallbackSafe(
root: TerminalPaneLayoutNode | null | undefined
): boolean {
return !root || root.type === 'leaf'
}
export function resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId,
activeLeafId,
agentStatusByPaneKey
}: {
terminalTabId: string
activeLeafId: string | null
agentStatusByPaneKey: Record<string, { agentType?: AgentType }>
}): AgentType | null {
if (activeLeafId) {
return agentStatusByPaneKey[`${terminalTabId}:${activeLeafId}`]?.agentType ?? null
}
return (
Object.entries(agentStatusByPaneKey).find(([paneKey]) =>
paneKey.startsWith(`${terminalTabId}:`)
)?.[1].agentType ?? null
)
}
/** Toggles the active worktree's focused agent-terminal tab between the terminal
* and native chat views via the keyboard. Gated to the active worktree so only
* one listener acts at a time, and to agent terminals so the chord is inert on
@ -37,19 +64,24 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive
// inert on unsupported agents like Grok, matching the menu/header gate.
// Pane keys are `${entityId}:${leafId}` — the backing terminal tab id, not
// the unified tab id.
const detectedAgent =
Object.entries(state.agentStatusByPaneKey).find(([paneKey]) =>
paneKey.startsWith(`${tab.entityId}:`)
)?.[1].agentType ?? null
const terminalLayout = state.terminalLayoutsByTabId[tab.entityId]
const activeLeafId = terminalLayout?.activeLeafId ?? null
const detectedAgent = resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: tab.entityId,
activeLeafId,
agentStatusByPaneKey: state.agentStatusByPaneKey
})
const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root)
? (resolveTabAgentFromTitle(tab.label ?? '') ??
(terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null))
: null
if (
!canToggleNativeChat({
experimentalNativeChatEnabled: state.settings?.experimentalNativeChat === true,
contentType: 'terminal',
launchAgent: terminalTab?.launchAgent,
launchAgent: detectedAgent ? null : terminalTab?.launchAgent,
detectedAgent,
resolvedAgent:
resolveTabAgentFromTitle(tab.label ?? '') ??
(terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null),
resolvedAgent: detectedAgent ? null : titleFallbackAgent,
isChatViewMode: tab.viewMode === 'chat'
})
) {

View File

@ -168,7 +168,7 @@ import {
} from './terminal-pane-attention-subscriptions'
import { getCachedTerminalTabForWorktree } from './terminal-tab-lookup'
import { getCachedTerminalGroupIdForWorktree } from './terminal-unified-tab-lookup'
import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent'
import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent'
import { useRepoById } from '@/store/selectors'
import {
isXtermHelperTextarea,
@ -591,6 +591,9 @@ export default function TerminalPane({
(t) => t.contentType === 'terminal' && t.entityId === tabId
)?.label
)
const runtimePaneTitlesByPaneId = useAppStore(
useShallow((store) => store.runtimePaneTitlesByTabId[tabId] ?? {})
)
// The native-chat toggle joins the pane header's split/close cluster. Eligible
// when Orca launched a *supported* agent here or one was detected live for the
// leaf, keyed `${tabId}:${leafId}`. Carry the agent identity, not just "an
@ -614,11 +617,17 @@ export default function TerminalPane({
const terminalTab = useAppStore((store) =>
getCachedTerminalTabForWorktree(store.tabsByWorktree, worktreeId, tabId)
)
// Why: manually-started/resumed TUIs can be recognized by the unified tab
// label before the backing terminal title or hook state catches up.
const titleResolvedAgent =
resolveTabAgentFromTitle(unifiedTabLabel ?? '') ??
(terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null)
const resolveTitleAgentForLeaf = useCallback(
(leafId: string | null) =>
resolveNativeChatLeafTitleAgent({
leafId,
panes: managerRef.current?.getPanes() ?? [],
runtimePaneTitlesByPaneId,
tabLabel: unifiedTabLabel,
terminalTitle: terminalTab?.title
}),
[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
@ -636,7 +645,7 @@ export default function TerminalPane({
contentType: 'terminal',
launchAgent: detectedAgent ? null : terminalTab?.launchAgent,
detectedAgent,
resolvedAgent: detectedAgent ? null : titleResolvedAgent,
resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId),
isChatViewMode: isChatViewForLeaf
})
},
@ -646,7 +655,7 @@ export default function TerminalPane({
chatLeafId,
nativeChatEnabled,
terminalTab?.launchAgent,
titleResolvedAgent
resolveTitleAgentForLeaf
]
)
const toggleNativeChatForLeaf = useCallback(
@ -2835,6 +2844,7 @@ export default function TerminalPane({
const chatPanePtyId = chatPane
? (paneTransportsRef.current.get(chatPane.id)?.getPtyId() ?? null)
: null
const chatPaneResolvedAgent = chatPane ? resolveTitleAgentForLeaf(chatPane.leafId) : null
const activePaneIsChatLeaf = Boolean(
isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId
)
@ -2933,6 +2943,7 @@ export default function TerminalPane({
paneKey={makePaneKey(tabId, chatPane.leafId)}
targetPtyId={chatPanePtyId}
launchAgent={terminalTab?.launchAgent}
resolvedAgent={chatPaneResolvedAgent}
onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)}
contextMenuActions={{
onSplitRight: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight),

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent'
const panes = [
{ id: 1, leafId: 'leaf-1' },
{ id: 2, leafId: 'leaf-2' }
]
describe('resolveNativeChatLeafTitleAgent', () => {
it('uses the target split leaf runtime title', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-2',
panes,
runtimePaneTitlesByPaneId: { 1: 'PowerShell', 2: 'Codex - working' },
tabLabel: 'PowerShell'
})
).toBe('codex')
})
it('does not reuse the active leaf tab label for an inactive split leaf', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-2',
panes,
runtimePaneTitlesByPaneId: { 1: 'Codex - working', 2: 'PowerShell' },
tabLabel: 'Codex - working'
})
).toBeNull()
})
it('does not reuse a stale tab label for the active split leaf without a runtime title', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-1',
panes,
runtimePaneTitlesByPaneId: {},
tabLabel: 'Claude Code'
})
).toBeNull()
})
it('falls back to the terminal title in a single pane', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-1',
panes: [panes[0]],
runtimePaneTitlesByPaneId: {},
terminalTitle: 'OpenClaude'
})
).toBe('openclaude')
})
})

View File

@ -0,0 +1,40 @@
import type { TuiAgent } from '../../../../shared/types'
import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent'
export type NativeChatLeafTitlePane = {
id: number
leafId: string
}
export type NativeChatLeafTitleAgentInput = {
leafId: string | null
panes: readonly NativeChatLeafTitlePane[]
runtimePaneTitlesByPaneId: Readonly<Record<number, string>>
tabLabel?: string | null
terminalTitle?: string | null
}
export function resolveNativeChatLeafTitleAgent({
leafId,
panes,
runtimePaneTitlesByPaneId,
tabLabel,
terminalTitle
}: NativeChatLeafTitleAgentInput): TuiAgent | null {
if (!leafId) {
return null
}
const targetPane = panes.find((pane) => pane.leafId === leafId)
const paneAgent = targetPane
? resolveTabAgentFromTitle(runtimePaneTitlesByPaneId[targetPane.id] ?? '')
: null
if (paneAgent) {
return paneAgent
}
// Tab titles can lag pane focus in split layouts, so use them only when there
// is no sibling leaf they could accidentally describe.
if (panes.length > 1) {
return null
}
return resolveTabAgentFromTitle(tabLabel ?? '') ?? resolveTabAgentFromTitle(terminalTitle ?? '')
}