mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Unify tab agent identity with the agent-status pipeline
Per Brennan's direction on this PR: tab icons must not run their own foreground-process probe. Identity now flows through the same already-computed state as the sidebar agent rows — hook entries (kept live by the OSC 133 command-finished drop machinery), explicit titles, and launch bootstrap. The probe's one unique local job, clearing stale launch identity after the launched agent exits, is replaced with probe-free evidence: a completed hook at a shell title, or a previously observed live hook row that was dropped (crash/kill path) while the title reads as a shell. Behavior trades, accepted with this direction: agents that never emit hooks resolve from title evidence or show no icon, and a done-but-open agent whose title reads as a shell shows no icon (matches the existing remote-pane behavior). Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -93,7 +93,7 @@ export default function SortableTab({
|
||||
// Older persisted tabs without this field fall back to the generic icon.
|
||||
const shellForIcon = tab.shellOverride
|
||||
|
||||
// Why: foreground process and hook status make the tab icon reflect the
|
||||
// Why: hook status and title evidence make the tab icon reflect the
|
||||
// coding harness currently running in the pane, not just the launch command.
|
||||
const tabAgent = useTabAgent(tab)
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ import { agentTypeToIconAgent } from './agent-status'
|
||||
|
||||
/**
|
||||
* Resolve a terminal tab's agent from hook-reported status. This is the
|
||||
* FALLBACK signal for the tab-bar icon — the live foreground process
|
||||
* (see useTabAgent) is the primary, dev-friendly source. Hook status is what
|
||||
* drives the icon for SSH/remote panes (where foreground polling is too
|
||||
* costly) and during the brief window before the first foreground poll lands.
|
||||
* PRIMARY identity signal for the tab-bar icon (see useTabAgent) — the same
|
||||
* already-computed state that drives the sidebar agent rows, kept live by the
|
||||
* OSC 133 command-finished machinery that drops entries when a process exits.
|
||||
*
|
||||
* Prefers the focused pane's agent so a split tab's icon tracks the pane in
|
||||
* view; falls back to any agent pane in the tab. Returns null when no pane
|
||||
|
||||
@@ -79,27 +79,10 @@ function twoPaneLayout(): TerminalLayoutSnapshot {
|
||||
}
|
||||
|
||||
describe('resolveTabAgentFromSignals', () => {
|
||||
it('uses a recognized foreground agent as the live local source of truth', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: 'codex',
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('keeps launch intent during the pre-start shell window', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: null,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: null,
|
||||
@@ -109,27 +92,23 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('lets shell foreground clear stale identity even when the title still names an agent', () => {
|
||||
it('trusts live hook identity at a shell title until the hook row is dropped', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: null,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: true,
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
title: 'zsh',
|
||||
hookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBeNull()
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('maps OpenClaude titles to the distinct OpenClaude tab icon', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '⠋ OpenClaude',
|
||||
hookAgent: null,
|
||||
@@ -142,9 +121,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('keeps title fallback for real Gemini, MiMo, and Pi titles', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✦ Gemini CLI',
|
||||
hookAgent: null,
|
||||
@@ -155,9 +132,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'MiMo Code',
|
||||
hookAgent: null,
|
||||
@@ -168,9 +143,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'π - my-project',
|
||||
hookAgent: null,
|
||||
@@ -183,9 +156,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it("uses completed OpenClaude hook identity over Claude's generic task-title heuristic", () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Say hi',
|
||||
hookAgent: null,
|
||||
@@ -199,9 +170,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('keeps launch identity over title identity while hooks have not arrived', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Say hi',
|
||||
hookAgent: null,
|
||||
@@ -214,9 +183,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it("keeps Codex launch intent over Claude's generic spinner title fallback", () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '⠸ codex-quarter-flash-202606191419',
|
||||
hookAgent: null,
|
||||
@@ -229,9 +196,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('does not infer Claude identity from a generic spinner title without context', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '⠸ investigating startup',
|
||||
hookAgent: null,
|
||||
@@ -245,9 +210,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
for (const title of ['. investigating startup', '* investigating startup', '✳ investigating']) {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title,
|
||||
hookAgent: null,
|
||||
@@ -261,9 +224,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('keeps launch identity over explicit title identity until stronger signals arrive', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '⠸ Claude Code',
|
||||
hookAgent: null,
|
||||
@@ -276,9 +237,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it("uses Codex hook identity over Claude's generic task-title heuristic", () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ improve-pr-actions-customization',
|
||||
hookAgent: 'codex',
|
||||
@@ -288,12 +247,10 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('keeps launch identity over explicit Claude Code titles without hook or foreground evidence', () => {
|
||||
it('keeps launch identity over explicit Claude Code titles without hook evidence', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
@@ -306,9 +263,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('lets an explicit title override stale launch identity after the pane shows newer activity', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
@@ -321,9 +276,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('does not let an explicit title override launch identity before any activity is observed', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
@@ -333,42 +286,10 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('lets shell foreground clear the icon after an agent was observed running', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: null,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: true,
|
||||
isRemote: false,
|
||||
title: 'zsh',
|
||||
hookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not let a pre-start shell sample suppress a later hook signal', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: null,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: 'codex',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('prefers explicit hook identity over a conflicting title mention', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Gemini CLI',
|
||||
hookAgent: 'claude',
|
||||
@@ -381,9 +302,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('prefers explicit hook identity over ordinary non-Claude title identity', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✦ Gemini CLI',
|
||||
hookAgent: 'claude',
|
||||
@@ -396,9 +315,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('lets focused-pane hook identity override launch metadata in split tabs', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: 'claude',
|
||||
@@ -412,9 +329,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('keeps unresolved launch metadata ahead of sibling-pane hook fallback', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: null,
|
||||
@@ -428,9 +343,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('uses sibling-pane hook fallback when no launch metadata exists', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: null,
|
||||
@@ -441,12 +354,10 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('keeps launch identity over Claude-owned task text without hook or foreground evidence', () => {
|
||||
it('keeps launch identity over Claude-owned task text without hook evidence', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Gemini CLI',
|
||||
hookAgent: null,
|
||||
@@ -459,9 +370,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('keeps launch identity over Claude-owned punctuation-prefixed task text', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '. Compare Opencode Vs Orca',
|
||||
hookAgent: null,
|
||||
@@ -472,9 +381,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '* Review Codex behavior',
|
||||
hookAgent: null,
|
||||
@@ -487,9 +394,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('treats Claude-prefixed title text as Claude only when it names Claude', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
@@ -500,9 +405,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '. Claude Code compare Opencode',
|
||||
hookAgent: null,
|
||||
@@ -512,12 +415,10 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('keeps local launch identity when only a shell title suggests exit', () => {
|
||||
it('keeps launch identity at a shell title until hook evidence proves exit', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'zsh',
|
||||
hookAgent: null,
|
||||
@@ -527,12 +428,10 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('skips local foreground authority for remote worktrees', () => {
|
||||
it('keeps hook identity for remote panes', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: null,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: true,
|
||||
isRemote: true,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: 'codex',
|
||||
@@ -545,9 +444,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
it('keeps completed remote hook identity after the terminal title returns to a shell', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: true,
|
||||
title: 'zsh',
|
||||
hookAgent: null,
|
||||
@@ -558,19 +455,21 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('keeps local launch identity after a completed hook until foreground proves shell exit', () => {
|
||||
it('clears local launch identity once a completed hook and shell title prove exit', () => {
|
||||
// Why: without foreground probing, a completed hook plus the title back at
|
||||
// a shell is the process-gone evidence — the same signals that clear the
|
||||
// sidebar row — so stale launch identity must not keep painting the tab.
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'zsh',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: true,
|
||||
completedHookAgent: 'claude',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe('claude')
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -619,103 +518,86 @@ describe('useTabAgent', () => {
|
||||
window.api = originalApi
|
||||
})
|
||||
|
||||
it('uses unrecognized non-shell foreground as launch lifecycle evidence', async () => {
|
||||
getForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('zsh')
|
||||
it('never probes the foreground process', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
useAppStore.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
|
||||
}
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: workingAgentStatus(paneKey)
|
||||
}
|
||||
})
|
||||
|
||||
const root = await renderHookProbe(baseTab)
|
||||
await rerenderHookProbe(root, { ...baseTab, title: '✳ Codex' })
|
||||
await rerenderHookProbe(root, { ...baseTab, title: 'zsh' })
|
||||
|
||||
expect(latestHookAgent).toBe('codex')
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not clear launch identity while the live hook row persists at a shell title', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
useAppStore.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
|
||||
}
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: workingAgentStatus(paneKey)
|
||||
}
|
||||
})
|
||||
|
||||
await renderHookProbe({ ...baseTab, title: 'zsh' })
|
||||
|
||||
expect(latestHookAgent).toBe('codex')
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears launch identity when a previously observed hook row drops at a shell title', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
useAppStore.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
|
||||
}
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: workingAgentStatus(paneKey)
|
||||
}
|
||||
})
|
||||
|
||||
const root = await renderHookProbe(baseTab)
|
||||
|
||||
expect(latestHookAgent).toBe('codex')
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
|
||||
// Why: crash/kill exits never fire a completed hook — the OSC 133
|
||||
// command-finished machinery drops the live entry and the title returns
|
||||
// to a shell. That already-computed evidence must clear launch intent.
|
||||
await act(async () => {
|
||||
useAppStore.setState({ agentStatusByPaneKey: {} })
|
||||
})
|
||||
await rerenderHookProbe(root, { ...baseTab, title: 'zsh' })
|
||||
|
||||
expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1')
|
||||
expect(latestHookAgent).toBeNull()
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries helper foreground so daemon-derived agent beats stale launch identity', async () => {
|
||||
vi.useFakeTimers()
|
||||
getForegroundProcess.mockResolvedValueOnce('uv').mockResolvedValueOnce('claude')
|
||||
|
||||
try {
|
||||
await renderHookProbe({ ...baseTab, launchAgent: 'opencode' })
|
||||
|
||||
expect(latestHookAgent).toBe('opencode')
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
})
|
||||
await flushHookEffects()
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
expect(latestHookAgent).toBe('claude')
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retry unknown helper foreground without launch intent', async () => {
|
||||
vi.useFakeTimers()
|
||||
getForegroundProcess.mockResolvedValue('uv')
|
||||
|
||||
try {
|
||||
await renderHookProbe({ ...baseTab, launchAgent: undefined })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
await flushHookEffects()
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(latestHookAgent).toBeNull()
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps one post-throttle shell retry to observe daemon-derived launch identity', async () => {
|
||||
vi.useFakeTimers()
|
||||
getForegroundProcess
|
||||
.mockResolvedValueOnce('zsh')
|
||||
.mockResolvedValueOnce('zsh')
|
||||
.mockResolvedValueOnce('zsh')
|
||||
.mockResolvedValueOnce('zsh')
|
||||
.mockResolvedValueOnce('claude')
|
||||
|
||||
try {
|
||||
await renderHookProbe({ ...baseTab, launchAgent: 'opencode' })
|
||||
|
||||
expect(latestHookAgent).toBe('opencode')
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250 + 1250 + 3500)
|
||||
})
|
||||
await flushHookEffects()
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(4)
|
||||
expect(latestHookAgent).toBe('opencode')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(750)
|
||||
})
|
||||
await flushHookEffects()
|
||||
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(5)
|
||||
expect(latestHookAgent).toBe('claude')
|
||||
} finally {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
|
||||
it('uses completed local hook status as launch lifecycle evidence after remount', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
getForegroundProcess.mockResolvedValueOnce('zsh')
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: completedAgentStatus(paneKey)
|
||||
@@ -724,9 +606,9 @@ describe('useTabAgent', () => {
|
||||
|
||||
await renderHookProbe({ ...baseTab, title: 'zsh' })
|
||||
|
||||
expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1')
|
||||
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
|
||||
expect(latestHookAgent).toBeNull()
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats paired runtime PTYs as remote-like for completed hook fallback', async () => {
|
||||
@@ -771,7 +653,6 @@ describe('useTabAgent', () => {
|
||||
|
||||
it('does not use completed sibling hook status as focused launch lifecycle evidence', async () => {
|
||||
const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID)
|
||||
getForegroundProcess.mockResolvedValueOnce('zsh')
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': twoPaneLayout() },
|
||||
@@ -789,6 +670,6 @@ describe('useTabAgent', () => {
|
||||
|
||||
expect(latestHookAgent).toBe('claude')
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-focus')
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: tab agent foreground state is synchronized from PTY/remote agent signals and shell foreground events. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { recognizeAgentProcess } from '../../../shared/agent-process-recognition'
|
||||
import { isShellProcess } from '../../../shared/agent-detection'
|
||||
import { worktreeUsesRemoteConnection } from '@/store/slices/terminals'
|
||||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
@@ -16,31 +14,8 @@ import type { TerminalTab, TuiAgent } from '../../../shared/types'
|
||||
|
||||
export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type'
|
||||
|
||||
const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const
|
||||
|
||||
function getTitleForegroundKey(title: string, launchAgent?: TuiAgent): string {
|
||||
const titleAgent = launchAgent ? null : resolveExplicitTerminalTitleAgentType(title)
|
||||
if (titleAgent) {
|
||||
return `agent:${titleAgent}`
|
||||
}
|
||||
if (isShellProcess(title)) {
|
||||
return 'shell'
|
||||
}
|
||||
const stableTitle = title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
// Why: unknown agents may still animate leading status glyphs. Include the
|
||||
// stable title body so first launch from "Terminal 1" triggers one poll,
|
||||
// without polling on every spinner frame.
|
||||
.replace(/^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/, '')
|
||||
.slice(0, 48)
|
||||
return `unknown:${stableTitle}`
|
||||
}
|
||||
|
||||
export function resolveTabAgentFromSignals(args: {
|
||||
foreground: TuiAgent | null | undefined
|
||||
hasObservedAgentSignal: boolean
|
||||
shellForegroundAfterAgentSignal: boolean
|
||||
isRemote: boolean
|
||||
title: string
|
||||
hookAgent: TuiAgent | null
|
||||
@@ -66,52 +41,36 @@ export function resolveTabAgentFromSignals(args: {
|
||||
? null
|
||||
: explicitTitleAgent
|
||||
const titleLooksShell = isShellProcess(args.title)
|
||||
// Why: remote panes cannot cheaply prove shell foreground after hook exit,
|
||||
// so keep the last completed hook identity instead of flashing unknown.
|
||||
// Why: remote pane titles can lag their runtime, so keep the last completed
|
||||
// hook identity instead of flashing unknown when the title reads as a shell.
|
||||
const completedHookAgent =
|
||||
!args.isRemote && titleLooksShell && args.hasCompletedHook ? null : args.completedHookAgent
|
||||
const focusedHookAgent = args.hookAgent ?? null
|
||||
const fallbackHookAgent = args.siblingHookAgent ?? completedHookAgent ?? null
|
||||
const localShellForegroundClearedLaunch =
|
||||
!args.isRemote && args.foreground === null && args.shellForegroundAfterAgentSignal
|
||||
const remoteCompletedHookAtShellTitle = args.isRemote && titleLooksShell && args.hasCompletedHook
|
||||
const activeLaunchAgent =
|
||||
localShellForegroundClearedLaunch || remoteCompletedHookAtShellTitle ? null : launchAgent
|
||||
// Why: titleAgent now ranks ahead of launch/fallback hooks because, once the
|
||||
// Why: a completed hook with the title back at a shell is this pipeline's
|
||||
// process-gone evidence — the same signals that clear the sidebar row.
|
||||
const completedHookAtShellTitle = titleLooksShell && args.hasCompletedHook
|
||||
const activeLaunchAgent = completedHookAtShellTitle ? null : launchAgent
|
||||
// Why: titleAgent ranks ahead of launch/fallback hooks because, once the
|
||||
// pane has shown activity, a live explicit title is the freshest identity
|
||||
// signal — it beats a launchAgent gone stale through pane reuse. Before any
|
||||
// activity, titleAgent is null while launchAgent exists, so launch bootstrap
|
||||
// still wins the startup window.
|
||||
if (args.isRemote || args.foreground === undefined) {
|
||||
return focusedHookAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent
|
||||
}
|
||||
if (args.foreground) {
|
||||
return args.foreground
|
||||
}
|
||||
// Why: once a local pane has returned to a shell, a stale hook should not keep
|
||||
// painting it as an agent tab.
|
||||
if (args.shellForegroundAfterAgentSignal) {
|
||||
return null
|
||||
}
|
||||
return focusedHookAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which coding-harness agent a terminal tab is running, for its tab-bar
|
||||
* icon. Layered signals, most-authoritative first:
|
||||
* icon. Identity flows through the same already-computed state as the sidebar
|
||||
* agent rows — no foreground probing. Layered signals, most-authoritative
|
||||
* first:
|
||||
*
|
||||
* 1. Live foreground process — the ground truth for what's running *now*: the
|
||||
* only signal that reverts to the terminal glyph when the agent exits to a
|
||||
* shell, or flips when a different agent starts in the same pane. Checked
|
||||
* event-driven (only when the tab's title changes — exactly when an agent
|
||||
* starts/exits/takes a turn), never on an interval, and only for local panes
|
||||
* (SSH foreground inspection is a 15s-timeout RPC). A recognized agent wins;
|
||||
* a recognized shell authoritatively means "no agent".
|
||||
* 2. Hook status — accurate provider identity from native integrations, and
|
||||
* available for SSH/remote panes where foreground polling is too costly.
|
||||
* 3. launchAgent — what Orca launched here; instant bootstrap before hooks or
|
||||
* foreground polling arrive, and the owned identity for startup windows.
|
||||
* 4. Title — legacy/unknown-session fallback, and the live override when a pane
|
||||
* 1. Hook status — provider identity from native integrations; the live entry
|
||||
* for the pane, dropped by the same OSC 133 command-finished machinery that
|
||||
* clears the sidebar row when the process exits.
|
||||
* 2. launchAgent — what Orca launched here; instant bootstrap before hooks
|
||||
* arrive, cleared once hook/title evidence shows the launched agent exited.
|
||||
* 3. Title — legacy/unknown-session fallback, and the live override when a pane
|
||||
* is reused: once the pane has shown activity, a title that explicitly names
|
||||
* a different agent than launchAgent wins over that stale launch identity.
|
||||
* Otherwise it is ignored while launchAgent exists, and generic spinner-only
|
||||
@@ -142,7 +101,8 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
const hasCompletedHook = focusedCompletedHookAgent !== null
|
||||
const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent)
|
||||
|
||||
// The focused pane's PTY (single-pane tabs have exactly one leaf).
|
||||
// The focused pane's PTY (single-pane tabs have exactly one leaf). Only used
|
||||
// to reset per-process-generation signals when the pane is respawned.
|
||||
const ptyId = useAppStore((s) => {
|
||||
const layout = s.terminalLayoutsByTabId[tab.id]
|
||||
const activeLeafId = layout?.activeLeafId
|
||||
@@ -151,8 +111,6 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
return leafPty
|
||||
}
|
||||
const ptyIds = s.ptyIdsByTabId[tab.id] ?? []
|
||||
// Why: without a focused leaf, a split tab's first PTY can be a sibling
|
||||
// shell. Only single-PTY fallback foreground is authoritative.
|
||||
return ptyIds.length === 1 ? ptyIds[0]! : null
|
||||
})
|
||||
const hasRemoteRuntimePty = useAppStore((s) => {
|
||||
@@ -166,128 +124,50 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
const isRemoteWorktree = useAppStore((s) => worktreeUsesRemoteConnection(s, tab.worktreeId))
|
||||
const isRemoteLike = isRemoteWorktree || hasRemoteRuntimePty
|
||||
|
||||
// undefined = no conclusive local reading (defer to title/hook/launchAgent);
|
||||
// null = foreground is a shell; TuiAgent = recognized agent process.
|
||||
const [foreground, setForeground] = useState<TuiAgent | null | undefined>(undefined)
|
||||
const [hasObservedAgentSignal, setHasObservedAgentSignal] = useState(false)
|
||||
const [shellForegroundAfterAgentSignal, setShellForegroundAfterAgentSignal] = useState(false)
|
||||
const hasObservedAgentSignalRef = useRef(false)
|
||||
const titleForegroundKey = getTitleForegroundKey(tab.title, tab.launchAgent)
|
||||
|
||||
useEffect(() => {
|
||||
setForeground(undefined)
|
||||
setHasObservedAgentSignal(false)
|
||||
hasObservedAgentSignalRef.current = false
|
||||
setShellForegroundAfterAgentSignal(false)
|
||||
}, [ptyId, isRemoteLike])
|
||||
|
||||
useEffect(() => {
|
||||
const fallbackAgentSignal =
|
||||
!tab.launchAgent && (resolveExplicitTerminalTitleAgentType(tab.title) || siblingHookAgent)
|
||||
// Why: a completed structured hook proves a launched agent existed, but
|
||||
// local launch cleanup still waits for current foreground-shell evidence.
|
||||
if (focusedHookAgent || hasCompletedHook || fallbackAgentSignal) {
|
||||
hasObservedAgentSignalRef.current = true
|
||||
setHasObservedAgentSignal(true)
|
||||
}
|
||||
}, [focusedHookAgent, hasCompletedHook, siblingHookAgent, tab.launchAgent, tab.title])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ptyId || isRemoteLike) {
|
||||
return
|
||||
}
|
||||
const localPtyId = ptyId
|
||||
let cancelled = false
|
||||
const helperForegroundRetryTimers: number[] = []
|
||||
// Why: re-runs when ptyId or tab.title changes — a title change is the event
|
||||
// signalling a possible foreground transition (agent start, exit, or turn).
|
||||
// One RPC per transition, not a timer; cancellation coalesces rapid churn.
|
||||
function readForeground(retryIndex = 0): void {
|
||||
window.api.pty
|
||||
.getForegroundProcess(localPtyId)
|
||||
.then((process) => {
|
||||
applyForegroundProcess(process, retryIndex)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setForeground(undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
function scheduleHelperForegroundRetry(retryIndex: number): void {
|
||||
const delay = HELPER_FOREGROUND_RETRY_DELAYS_MS[retryIndex]
|
||||
if (delay === undefined) {
|
||||
return
|
||||
}
|
||||
// Why: the daemon resolves shell/helper -> agent ancestry asynchronously
|
||||
// after the first foreground read, so give its short cache a bounded re-read.
|
||||
const timer = window.setTimeout(() => {
|
||||
readForeground(retryIndex + 1)
|
||||
}, delay)
|
||||
helperForegroundRetryTimers.push(timer)
|
||||
}
|
||||
function applyForegroundProcess(process: string | null, retryIndex: number): void {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
const recognized = recognizeAgentProcess(process)
|
||||
if (recognized) {
|
||||
hasObservedAgentSignalRef.current = true
|
||||
setHasObservedAgentSignal(true)
|
||||
setForeground(recognized.agent)
|
||||
} else if (process && isShellProcess(process)) {
|
||||
setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current)
|
||||
setForeground(null)
|
||||
if (tab.launchAgent && !hasObservedAgentSignalRef.current) {
|
||||
scheduleHelperForegroundRetry(retryIndex)
|
||||
}
|
||||
} else {
|
||||
if (process && tab.launchAgent) {
|
||||
// Why: for Orca-owned launches, an unrecognized non-shell process
|
||||
// is enough lifecycle evidence to clear launch intent when the pane
|
||||
// later returns to a shell, without using title text as identity.
|
||||
hasObservedAgentSignalRef.current = true
|
||||
setHasObservedAgentSignal(true)
|
||||
}
|
||||
setForeground(undefined)
|
||||
if (process && tab.launchAgent) {
|
||||
scheduleHelperForegroundRetry(retryIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
readForeground()
|
||||
return () => {
|
||||
cancelled = true
|
||||
helperForegroundRetryTimers.forEach((timer) => window.clearTimeout(timer))
|
||||
}
|
||||
}, [ptyId, isRemoteLike, tab.launchAgent, titleForegroundKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tab.launchAgent) {
|
||||
return
|
||||
}
|
||||
const titleLooksShell = isShellProcess(tab.title)
|
||||
const foregroundSawExitedAgent =
|
||||
!isRemoteLike && foreground === null && shellForegroundAfterAgentSignal
|
||||
const remoteHookCompletedAtShellTitle = isRemoteLike && hasCompletedHook && titleLooksShell
|
||||
if (foregroundSawExitedAgent || remoteHookCompletedAtShellTitle) {
|
||||
// Why: launched-agent exit evidence without probing — either the hook
|
||||
// completed, or the pane's live hook row was dropped by command-finished
|
||||
// after the pane had shown agent activity, while the title reads as a
|
||||
// shell again. Crash/kill paths land in the second disjunct because the
|
||||
// OSC 133 drop removes the live entry without a completed hook.
|
||||
const launchedAgentExited =
|
||||
titleLooksShell && (hasCompletedHook || (hasObservedAgentSignal && !focusedHookAgent))
|
||||
if (launchedAgentExited) {
|
||||
clearTabLaunchAgent(tab.id)
|
||||
}
|
||||
}, [
|
||||
clearTabLaunchAgent,
|
||||
foreground,
|
||||
focusedHookAgent,
|
||||
hasCompletedHook,
|
||||
isRemoteLike,
|
||||
shellForegroundAfterAgentSignal,
|
||||
hasObservedAgentSignal,
|
||||
tab.id,
|
||||
tab.launchAgent,
|
||||
tab.title
|
||||
])
|
||||
|
||||
return resolveTabAgentFromSignals({
|
||||
foreground,
|
||||
hasObservedAgentSignal,
|
||||
shellForegroundAfterAgentSignal,
|
||||
isRemote: isRemoteLike,
|
||||
title: tab.title,
|
||||
hookAgent: focusedHookAgent,
|
||||
|
||||
Reference in New Issue
Block a user