Prevent tab icons from briefly showing the wrong agent (#5860)

This commit is contained in:
Brennan Benson
2026-06-19 19:07:16 -07:00
committed by GitHub
parent c43571c68c
commit 202827ecfa
6 changed files with 629 additions and 84 deletions
+41 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest'
import { hasCompletedTabAgent, resolveCompletedTabAgent, resolveTabAgent } from './tab-agent'
import {
hasCompletedTabAgent,
resolveCompletedTabAgent,
resolveFocusedCompletedTabAgent,
resolveFocusedTabAgent,
resolveSiblingCompletedTabAgent,
resolveSiblingTabAgent,
resolveTabAgent
} from './tab-agent'
import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-types'
import type { TerminalLayoutSnapshot } from '../../../shared/types'
@@ -40,6 +48,15 @@ describe('resolveTabAgent', () => {
expect(resolveTabAgent(map, layout(LEAF_B), 'tab-1')).toBe('codex')
})
it('exposes focused and sibling hook identity separately', () => {
const map = {
[`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'claude'),
[`tab-1:${LEAF_B}`]: entry(`tab-1:${LEAF_B}`, 'codex')
}
expect(resolveFocusedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('claude')
expect(resolveSiblingTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex')
})
it('falls back to any agent pane when the focused pane is a plain terminal', () => {
// Focused leaf A has no entry (it's a shell); the split sibling runs Codex.
const map = { [`tab-1:${LEAF_B}`]: entry(`tab-1:${LEAF_B}`, 'codex') }
@@ -51,6 +68,13 @@ describe('resolveTabAgent', () => {
expect(resolveTabAgent(map, undefined, 'tab-1')).toBe('droid')
})
it('treats same-tab hook identity as focused when the layout is missing', () => {
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'codex') }
expect(resolveFocusedTabAgent(map, undefined, 'tab-1')).toBe('codex')
expect(resolveSiblingTabAgent(map, undefined, 'tab-1')).toBeNull()
})
it("keeps the terminal glyph for an agent that didn't identify itself", () => {
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'unknown') }
expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBeNull()
@@ -77,6 +101,22 @@ describe('resolveTabAgent', () => {
expect(resolveCompletedTabAgent(map, 'tab-1')).toBe('openclaude')
})
it('exposes focused and sibling completed hook identity separately', () => {
const map = {
[`tab-1:${LEAF_A}`]: {
...entry(`tab-1:${LEAF_A}`, 'claude'),
state: 'done' as const
},
[`tab-1:${LEAF_B}`]: {
...entry(`tab-1:${LEAF_B}`, 'codex'),
state: 'done' as const
}
}
expect(resolveFocusedCompletedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('claude')
expect(resolveSiblingCompletedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex')
})
it('keeps the terminal glyph for an agent Orca has no icon for', () => {
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'totally-custom-agent') }
expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBeNull()
+84 -7
View File
@@ -18,16 +18,48 @@ export function resolveTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
tabId: string
): TuiAgent | null {
return (
resolveFocusedTabAgent(agentStatusByPaneKey, layout, tabId) ??
resolveSiblingTabAgent(agentStatusByPaneKey, layout, tabId)
)
}
export function resolveFocusedTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
tabId: string
): TuiAgent | null {
const activeLeafId = layout?.activeLeafId
if (activeLeafId && isTerminalLeafId(activeLeafId)) {
const focused = agentFromStatusEntry(agentStatusByPaneKey[makePaneKey(tabId, activeLeafId)])
if (focused) {
return focused
}
return agentFromStatusEntry(agentStatusByPaneKey[makePaneKey(tabId, activeLeafId)])
}
// Why: hook events can arrive while the terminal layout is temporarily
// unmounted; with no focused leaf to compare, same-tab hook status is primary.
return resolveAnyTabAgent(agentStatusByPaneKey, tabId)
}
export function resolveSiblingTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
tabId: string
): TuiAgent | null {
const activeLeafId =
layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId) ? layout.activeLeafId : null
if (!activeLeafId) {
return null
}
return resolveAnyTabAgent(agentStatusByPaneKey, tabId, activeLeafId)
}
function resolveAnyTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
tabId: string,
excludedLeafId?: string
): TuiAgent | null {
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
if (parsePaneKey(paneKey)?.tabId === tabId) {
const parsedPaneKey = parsePaneKey(paneKey)
if (parsedPaneKey?.tabId === tabId && parsedPaneKey.leafId !== excludedLeafId) {
const agent = agentFromStatusEntry(entry)
if (agent) {
return agent
@@ -53,11 +85,49 @@ export function hasCompletedTabAgent(
export function resolveCompletedTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
tabId: string,
layout?: TerminalLayoutSnapshot
): TuiAgent | null {
return (
resolveFocusedCompletedTabAgent(agentStatusByPaneKey, layout, tabId) ??
resolveSiblingCompletedTabAgent(agentStatusByPaneKey, layout, tabId)
)
}
export function resolveFocusedCompletedTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
tabId: string
): TuiAgent | null {
const activeLeafId = layout?.activeLeafId
if (activeLeafId && isTerminalLeafId(activeLeafId)) {
return completedAgentFromStatusEntry(agentStatusByPaneKey[makePaneKey(tabId, activeLeafId)])
}
return resolveAnyCompletedTabAgent(agentStatusByPaneKey, tabId)
}
export function resolveSiblingCompletedTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
tabId: string
): TuiAgent | null {
const activeLeafId =
layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId) ? layout.activeLeafId : null
if (!activeLeafId) {
return null
}
return resolveAnyCompletedTabAgent(agentStatusByPaneKey, tabId, activeLeafId)
}
function resolveAnyCompletedTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
tabId: string,
excludedLeafId?: string
): TuiAgent | null {
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
if (entry.state === 'done' && parsePaneKey(paneKey)?.tabId === tabId) {
const agent = agentTypeToIconAgent(entry.agentType)
const parsedPaneKey = parsePaneKey(paneKey)
if (parsedPaneKey?.tabId === tabId && parsedPaneKey.leafId !== excludedLeafId) {
const agent = completedAgentFromStatusEntry(entry)
if (agent) {
return agent
}
@@ -65,3 +135,10 @@ export function resolveCompletedTabAgent(
}
return null
}
function completedAgentFromStatusEntry(entry: AgentStatusEntry | undefined): TuiAgent | null {
if (!entry || entry.state !== 'done') {
return null
}
return agentTypeToIconAgent(entry.agentType)
}
+373 -31
View File
@@ -1,5 +1,82 @@
import { describe, expect, it } from 'vitest'
import { resolveTabAgentFromSignals } from './use-tab-agent'
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalTab, TuiAgent } from '../../../shared/types'
import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent'
const initialAppState = useAppStore.getInitialState()
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
let latestHookAgent: TuiAgent | null | undefined
const hookRoots: Root[] = []
function HookProbe({ tab }: { tab: TerminalTab }): null {
latestHookAgent = useTabAgent(tab)
return null
}
async function renderHookProbe(tab: TerminalTab): Promise<Root> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
hookRoots.push(root)
await act(async () => {
root.render(createElement(HookProbe, { tab }))
})
await flushHookEffects()
return root
}
async function rerenderHookProbe(root: Root, tab: TerminalTab): Promise<void> {
await act(async () => {
root.render(createElement(HookProbe, { tab }))
})
await flushHookEffects()
}
async function flushHookEffects(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
function agentStatus(paneKey: string, state: AgentStatusEntry['state']): AgentStatusEntry {
return {
state,
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
agentType: 'codex',
paneKey,
stateHistory: []
}
}
function completedAgentStatus(paneKey: string): AgentStatusEntry {
return agentStatus(paneKey, 'done')
}
function workingAgentStatus(paneKey: string): AgentStatusEntry {
return agentStatus(paneKey, 'working')
}
function twoPaneLayout(): TerminalLayoutSnapshot {
return {
root: null,
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: {
[LEAF_ID]: 'pty-focus',
[SECOND_LEAF_ID]: 'pty-sibling'
}
}
}
describe('resolveTabAgentFromSignals', () => {
it('uses a recognized foreground agent as the live local source of truth', () => {
@@ -57,7 +134,7 @@ describe('resolveTabAgentFromSignals', () => {
title: '⠋ OpenClaude',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'claude'
launchAgent: undefined
})
).toBe('openclaude')
})
@@ -106,7 +183,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('openclaude')
})
it('uses Claude-owned title identity before OpenClaude launch intent when hooks have not arrived', () => {
it('keeps launch identity over title identity while hooks have not arrived', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
@@ -118,7 +195,69 @@ describe('resolveTabAgentFromSignals', () => {
hasCompletedHook: false,
launchAgent: 'openclaude'
})
).toBe('claude')
).toBe('openclaude')
})
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,
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
})
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,
hasCompletedHook: false,
launchAgent: undefined
})
).toBeNull()
})
it('does not infer Claude identity from generic dot or star status titles', () => {
for (const title of ['. investigating startup', '* investigating startup', '✳ investigating']) {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title,
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBeNull()
}
})
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,
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
})
it("uses Codex hook identity over Claude's generic task-title heuristic", () => {
@@ -136,7 +275,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('keeps explicit Claude Code titles authoritative over stale OpenClaude launch intent', () => {
it('keeps launch identity over explicit Claude Code titles without hook or foreground evidence', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
@@ -148,7 +287,7 @@ describe('resolveTabAgentFromSignals', () => {
hasCompletedHook: false,
launchAgent: 'openclaude'
})
).toBe('claude')
).toBe('openclaude')
})
it('lets shell foreground clear the icon after an agent was observed running', () => {
@@ -211,7 +350,55 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('claude')
})
it('does not let launch intent turn Claude-owned task text into Gemini', () => {
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',
siblingHookAgent: 'gemini',
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('claude')
})
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,
siblingHookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
})
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,
siblingHookAgent: 'claude',
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('claude')
})
it('keeps launch identity over Claude-owned task text without hook or foreground evidence', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
@@ -223,10 +410,10 @@ describe('resolveTabAgentFromSignals', () => {
hasCompletedHook: false,
launchAgent: 'gemini'
})
).toBe('claude')
).toBe('gemini')
})
it('does not let launch intent turn Claude-owned task text into OpenCode', () => {
it('keeps launch identity over Claude-owned punctuation-prefixed task text', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
@@ -238,7 +425,7 @@ describe('resolveTabAgentFromSignals', () => {
hasCompletedHook: false,
launchAgent: 'opencode'
})
).toBe('claude')
).toBe('opencode')
expect(
resolveTabAgentFromSignals({
@@ -251,35 +438,50 @@ describe('resolveTabAgentFromSignals', () => {
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
})
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,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('claude')
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '. Claude Code compare Opencode',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('claude')
})
it('treats Claude-prefixed task text as Claude before launch intent when no hook arrived', () => {
it('keeps local launch identity when only a shell title suggests exit', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Gemini CLI',
title: 'zsh',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
launchAgent: 'codex'
})
).toBe('claude')
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '. Compare Opencode Vs Orca',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('claude')
).toBe('codex')
})
it('skips local foreground authority for remote worktrees', () => {
@@ -313,7 +515,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('suppresses stale local launch intent after a completed hook and shell title', () => {
it('keeps local launch identity after a completed hook until foreground proves shell exit', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
@@ -325,6 +527,146 @@ describe('resolveTabAgentFromSignals', () => {
hasCompletedHook: true,
launchAgent: 'claude'
})
).toBeNull()
).toBe('claude')
})
})
describe('useTabAgent', () => {
const originalApi = window.api
const getForegroundProcess = vi.fn()
const clearTabLaunchAgent = vi.fn()
const baseTab: TerminalTab = {
id: 'tab-1',
ptyId: 'pty-1',
worktreeId: 'wt-1',
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
launchAgent: 'codex'
}
beforeEach(() => {
latestHookAgent = undefined
getForegroundProcess.mockReset()
clearTabLaunchAgent.mockReset()
useAppStore.setState(initialAppState, true)
useAppStore.setState({
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
agentStatusByPaneKey: {},
terminalLayoutsByTabId: {},
clearTabLaunchAgent
})
window.api = {
...originalApi,
pty: {
...originalApi?.pty,
getForegroundProcess
}
} as typeof window.api
})
afterEach(() => {
hookRoots.splice(0).forEach((root) => {
act(() => root.unmount())
})
document.body.replaceChildren()
useAppStore.setState(initialAppState, true)
window.api = originalApi
})
it('uses unrecognized non-shell foreground as launch lifecycle evidence', async () => {
getForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('zsh')
const root = await renderHookProbe(baseTab)
expect(latestHookAgent).toBe('codex')
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
await rerenderHookProbe(root, { ...baseTab, title: 'zsh' })
expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1')
expect(latestHookAgent).toBeNull()
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
})
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)
}
})
await renderHookProbe({ ...baseTab, title: 'zsh' })
expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1')
expect(latestHookAgent).toBeNull()
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
})
it('treats paired runtime PTYs as remote-like for completed hook fallback', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
useAppStore.setState({
ptyIdsByTabId: { 'tab-1': ['remote:web-env-1@@terminal-1'] },
agentStatusByPaneKey: {
[paneKey]: completedAgentStatus(paneKey)
}
})
await renderHookProbe({
...baseTab,
ptyId: 'remote:web-env-1@@terminal-1',
title: 'zsh',
launchAgent: undefined
})
expect(latestHookAgent).toBe('codex')
expect(getForegroundProcess).not.toHaveBeenCalled()
})
it('does not let a split-tab fallback PTY suppress missing-layout hook identity', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
useAppStore.setState({
ptyIdsByTabId: { 'tab-1': ['pty-shell', 'pty-agent'] },
terminalLayoutsByTabId: {},
agentStatusByPaneKey: {
[paneKey]: workingAgentStatus(paneKey)
}
})
await renderHookProbe({
...baseTab,
title: 'zsh',
launchAgent: 'claude'
})
expect(latestHookAgent).toBe('codex')
expect(getForegroundProcess).not.toHaveBeenCalled()
})
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() },
agentStatusByPaneKey: {
[siblingPaneKey]: completedAgentStatus(siblingPaneKey)
}
})
await renderHookProbe({
...baseTab,
ptyId: 'pty-focus',
title: 'zsh',
launchAgent: 'claude'
})
expect(latestHookAgent).toBe('claude')
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-focus')
})
})
+125 -36
View File
@@ -2,9 +2,15 @@
import { useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { recognizeAgentProcess } from '../../../shared/agent-process-recognition'
import { isShellProcess, getAgentLabel } from '../../../shared/agent-detection'
import { isShellProcess, getAgentLabel, titleHasAgentName } from '../../../shared/agent-detection'
import { worktreeUsesRemoteConnection } from '@/store/slices/terminals'
import { resolveCompletedTabAgent, resolveTabAgent } from './tab-agent'
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import {
resolveFocusedCompletedTabAgent,
resolveFocusedTabAgent,
resolveSiblingCompletedTabAgent,
resolveSiblingTabAgent
} from './tab-agent'
import type { TerminalTab, TuiAgent } from '../../../shared/types'
// Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for
@@ -33,8 +39,47 @@ function agentFromTitle(title: string): TuiAgent | null {
return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null
}
function getTitleForegroundKey(title: string): string {
function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
const codePoint = char.codePointAt(0)
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
return true
}
}
return false
}
function hasGenericClaudeStatusPrefix(title: string): boolean {
return (
containsBrailleSpinner(title) ||
title.startsWith('✳ ') ||
title === '✳' ||
title.startsWith('. ') ||
title.startsWith('* ')
)
}
function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): boolean {
return (
titleAgent === 'claude' &&
hasGenericClaudeStatusPrefix(title) &&
!titleHasAgentName(title, 'claude')
)
}
function agentFromTabTitle(title: string): TuiAgent | null {
const titleAgent = agentFromTitle(title)
if (isGenericClaudeStatusClaim(title, titleAgent)) {
// Why: bare Claude status prefixes are activity evidence, not identity.
// Keep them out of tab icons so task/worktree titles cannot become Claude
// without a hook, launch intent, foreground process, or explicit name.
return null
}
return titleAgent
}
function getTitleForegroundKey(title: string, launchAgent?: TuiAgent): string {
const titleAgent = launchAgent ? null : agentFromTabTitle(title)
if (titleAgent) {
return `agent:${titleAgent}`
}
@@ -59,23 +104,27 @@ export function resolveTabAgentFromSignals(args: {
isRemote: boolean
title: string
hookAgent: TuiAgent | null
siblingHookAgent?: TuiAgent | null
hasCompletedHook: boolean
completedHookAgent?: TuiAgent | null
launchAgent?: TuiAgent
}): TuiAgent | null {
const titleAgent = agentFromTitle(args.title)
const launchAgent = args.launchAgent ?? null
const titleAgent = launchAgent ? null : agentFromTabTitle(args.title)
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.
const completedHookAgent =
!args.isRemote && titleLooksShell && args.hasCompletedHook ? null : args.completedHookAgent
const hookAgent = args.hookAgent ?? completedHookAgent ?? null
const launchAgent =
args.hasCompletedHook || (titleLooksShell && args.hasObservedAgentSignal)
? null
: (args.launchAgent ?? null)
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
if (args.isRemote || args.foreground === undefined) {
return hookAgent ?? titleAgent ?? launchAgent
return focusedHookAgent ?? activeLaunchAgent ?? fallbackHookAgent ?? titleAgent
}
if (args.foreground) {
return args.foreground
@@ -85,7 +134,7 @@ export function resolveTabAgentFromSignals(args: {
if (args.shellForegroundAfterAgentSignal) {
return null
}
return hookAgent ?? titleAgent ?? launchAgent
return focusedHookAgent ?? activeLaunchAgent ?? fallbackHookAgent ?? titleAgent
}
/**
@@ -101,18 +150,34 @@ export function resolveTabAgentFromSignals(args: {
* 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. Title — catches agents whose process name isn't self-identifying (Claude
* runs as `node`; its "✳ Claude Code" title still identifies it).
* 4. launchAgent — what Orca launched here; instant bootstrap before any check.
* 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 only. It is ignored while
* launchAgent exists, and generic spinner-only titles do not identify an agent.
*/
export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const hookAgent = useAppStore((s) =>
resolveTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id)
const focusedHookAgent = useAppStore((s) =>
resolveFocusedTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id)
)
const completedHookAgent = useAppStore((s) =>
resolveCompletedTabAgent(s.agentStatusByPaneKey, tab.id)
const siblingHookAgent = useAppStore((s) =>
resolveSiblingTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id)
)
const hasCompletedHook = completedHookAgent !== null
const focusedCompletedHookAgent = useAppStore((s) =>
resolveFocusedCompletedTabAgent(
s.agentStatusByPaneKey,
s.terminalLayoutsByTabId[tab.id],
tab.id
)
)
const siblingCompletedHookAgent = useAppStore((s) =>
resolveSiblingCompletedTabAgent(
s.agentStatusByPaneKey,
s.terminalLayoutsByTabId[tab.id],
tab.id
)
)
const completedHookAgent = focusedCompletedHookAgent ?? siblingCompletedHookAgent
const hasCompletedHook = focusedCompletedHookAgent !== null
const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent)
// The focused pane's PTY (single-pane tabs have exactly one leaf).
@@ -120,9 +185,24 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const layout = s.terminalLayoutsByTabId[tab.id]
const activeLeafId = layout?.activeLeafId
const leafPty = activeLeafId ? layout?.ptyIdsByLeafId?.[activeLeafId] : undefined
return leafPty ?? s.ptyIdsByTabId[tab.id]?.[0] ?? null
if (leafPty) {
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 isRemote = useAppStore((s) => worktreeUsesRemoteConnection(s, tab.worktreeId))
const hasRemoteRuntimePty = useAppStore((s) => {
const layout = s.terminalLayoutsByTabId[tab.id]
const ptyIds = new Set(s.ptyIdsByTabId[tab.id] ?? [])
for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) {
ptyIds.add(ptyId)
}
return [...ptyIds].some((ptyId) => parseRemoteRuntimePtyId(ptyId) !== 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.
@@ -130,24 +210,28 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const [hasObservedAgentSignal, setHasObservedAgentSignal] = useState(false)
const [shellForegroundAfterAgentSignal, setShellForegroundAfterAgentSignal] = useState(false)
const hasObservedAgentSignalRef = useRef(false)
const titleForegroundKey = getTitleForegroundKey(tab.title)
const titleForegroundKey = getTitleForegroundKey(tab.title, tab.launchAgent)
useEffect(() => {
setForeground(undefined)
setHasObservedAgentSignal(false)
hasObservedAgentSignalRef.current = false
setShellForegroundAfterAgentSignal(false)
}, [ptyId, isRemote])
}, [ptyId, isRemoteLike])
useEffect(() => {
if (agentFromTitle(tab.title) || hookAgent) {
const fallbackAgentSignal =
!tab.launchAgent && (agentFromTabTitle(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)
}
}, [hookAgent, tab.title])
}, [focusedHookAgent, hasCompletedHook, siblingHookAgent, tab.launchAgent, tab.title])
useEffect(() => {
if (!ptyId || isRemote) {
if (!ptyId || isRemoteLike) {
return
}
let cancelled = false
@@ -169,6 +253,13 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current)
setForeground(null)
} 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)
}
})
@@ -180,27 +271,24 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
return () => {
cancelled = true
}
}, [ptyId, isRemote, titleForegroundKey])
}, [ptyId, isRemoteLike, tab.launchAgent, titleForegroundKey])
useEffect(() => {
if (!tab.launchAgent) {
return
}
const titleLooksShell = isShellProcess(tab.title)
const titleAgent = agentFromTitle(tab.title)
const foregroundSawExitedAgent =
!isRemote && foreground === null && shellForegroundAfterAgentSignal && !titleAgent
const titleSawExitedAgent = titleLooksShell && hasObservedAgentSignal
const remoteHookCompletedAtShellTitle = isRemote && hasCompletedHook && titleLooksShell
if (foregroundSawExitedAgent || titleSawExitedAgent || remoteHookCompletedAtShellTitle) {
!isRemoteLike && foreground === null && shellForegroundAfterAgentSignal
const remoteHookCompletedAtShellTitle = isRemoteLike && hasCompletedHook && titleLooksShell
if (foregroundSawExitedAgent || remoteHookCompletedAtShellTitle) {
clearTabLaunchAgent(tab.id)
}
}, [
clearTabLaunchAgent,
foreground,
hasCompletedHook,
hasObservedAgentSignal,
isRemote,
isRemoteLike,
shellForegroundAfterAgentSignal,
tab.id,
tab.launchAgent,
@@ -211,9 +299,10 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
foreground,
hasObservedAgentSignal,
shellForegroundAfterAgentSignal,
isRemote,
isRemote: isRemoteLike,
title: tab.title,
hookAgent,
hookAgent: focusedHookAgent,
siblingHookAgent,
hasCompletedHook,
completedHookAgent,
launchAgent: tab.launchAgent
@@ -629,7 +629,7 @@ describe('applyWebSessionTabsSnapshot', () => {
expect(patch.activeTabIdByWorktree?.[WT]).toBe(mirroredId)
})
it('preserves mirrored launch intent when a later host snapshot omits it', () => {
it('drops mirrored launch intent when a later host snapshot omits it', () => {
const existingTab: TerminalTab = {
id: toWebTerminalSurfaceTabId('host-tab-1'),
ptyId: 'remote:web-env-1@@terminal-1',
@@ -666,9 +666,9 @@ describe('applyWebSessionTabsSnapshot', () => {
expect(patch.tabsByWorktree?.[WT]?.[0]).toMatchObject({
id: existingTab.id,
title: 'zsh',
launchAgent: 'codex'
title: 'zsh'
})
expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgent).toBeUndefined()
})
it('preserves quick command labels from host terminal surfaces', () => {
@@ -507,9 +507,7 @@ function buildMirroredTerminalTabs(
surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() ||
existing?.quickCommandLabel?.trim()
const launchAgent =
activeSurface.launchAgent ??
surfaces.find((surface) => surface.launchAgent)?.launchAgent ??
existing?.launchAgent
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
// Why: tab color/pin echo back through host snapshots, so prefer the client's
// own record (kept authoritative in tabsByWorktree by the pin/color setters)
// and fall back to the host value only when this client has no prior tab —
@@ -533,9 +531,8 @@ function buildMirroredTerminalTabs(
isPinned,
sortOrder: sortOffset + index,
createdAt: existing?.createdAt ?? now + index,
// Why: runtime snapshots can omit launchAgent after the process settles;
// keep the client-side launch intent so completed remote tabs do not
// briefly lose their provider icon between host status snapshots.
// Why: launchAgent is host-owned lifecycle metadata. If the host stops
// publishing it, mirrored clients must not resurrect stale startup intent.
...(launchAgent ? { launchAgent } : {})
},
hostTabId: parentTabId,