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:
parent
19bb7df93a
commit
9fa1313179
|
|
@ -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)}</>
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
})
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 ?? '')
|
||||
}
|
||||
Loading…
Reference in New Issue