mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(native-chat): allow zero-message agent sessions
This commit is contained in:
@@ -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') })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user