From 67fdb1c2b929927b07bdb2bfcdb66dc15f8a6f98 Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:32:08 +0200 Subject: [PATCH] Show agent status in terminal tabs (#8142) * Show agent status in terminal tabs * Resolve terminal tab activity via the canonical worktree-status engine The first pass hand-rolled resolveTerminalTabAgentActivityState, a fourth parallel copy of the pane-iteration/freshness/title-heuristic loop that already lives in smart-attention.ts and worktree-agent-activity-summary.ts. It diverged from every existing surface (novel blocked>waiting split, a phantom 'interrupted' red state the sidebar treats as idle) and re-scanned the global agentStatusByPaneKey map per tab per store write (O(tabs*agents)). Replace it with resolveTerminalTabActivityStatus, which reuses resolveWorktreeStatus (the WorktreeCard resolver: freshness gate, live-PTY liveness, per-leaf title dedup, permission>working>done priority) over a per-tab flag summary bucketed once per store snapshot (O(tabs+agents)). Tabs now speak the same WorktreeStatus vocabulary as the sidebar, so their live states can't disagree with the worktree card. - Map WorktreeStatus -> AgentStateDot: working=spinner, permission=amber, done=check; active/inactive fall through to the agent/shell identity icon. - Parse legacy numeric pane keys too, matching the sidebar summary, so restored/ imported sessions light the tab. - Drop the bespoke resolver + its tests; add focused coverage for the new resolver and the leading-icon component. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../src/components/AgentStateDot.test.ts | 1 + src/renderer/src/components/AgentStateDot.tsx | 4 +- .../src/components/tab-bar/SortableTab.tsx | 97 +++++----- .../tab-bar/TerminalTabLeadingIcon.test.tsx | 85 +++++++++ .../tab-bar/TerminalTabLeadingIcon.tsx | 114 ++++++++++++ .../terminal-tab-activity-status.test.ts | 171 ++++++++++++++++++ .../tab-bar/terminal-tab-activity-status.ts | 159 ++++++++++++++++ 7 files changed, 575 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx create mode 100644 src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts create mode 100644 src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index e02c5d496ce..2e17d0423df 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -23,6 +23,7 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') + expect(markup).toContain('motion-reduce:animate-none') expect(markup).not.toContain('animate-spin') }) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 951e3be36f1..9a4fe792e13 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -30,6 +30,7 @@ export type AgentDotState = // worktree-level permission dot. | 'permission' +/** Return the accessible label shared by every visual agent-state marker. */ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': @@ -57,6 +58,7 @@ type Props = { className?: string } +/** Render the compact state glyph used by agent rows and terminal tabs. */ export const AgentStateDot = React.memo(function AgentStateDot({ state, size = 'sm', @@ -76,7 +78,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ className={cn( // Why: match the sidebar worktree spinner's stepped cadence so // long-running visible agents do not keep a full-frame-rate loop. - 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]', + 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none', inner )} /> diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 00beaf35b69..abf4b49cabd 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { X, Minimize2, Pin } from 'lucide-react' -import { ShellIcon } from './shell-icons' -import { AgentIcon } from '@/lib/agent-catalog' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' @@ -11,7 +9,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' -import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' import { useAppStore } from '../../store' import { ACTIVE_TAB_INDICATOR_CLASSES, @@ -26,6 +23,12 @@ import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' +import { + hasUnreadAgentCompletionForTerminalTab, + isTerminalTabActivityLive, + resolveTerminalTabActivityStatus +} from './terminal-tab-activity-status' type SortableTabProps = { tab: TerminalTab @@ -82,11 +85,26 @@ export default function SortableTab({ isChatView = false, onToggleViewMode }: SortableTabProps): React.JSX.Element { - // Why: subscribe to the per-tab boolean directly so only the tab whose unread - // status actually flipped re-renders. Reading the whole `unreadTerminalTabs` - // map in TabBar would invalidate every SortableTab on every bell event - // because the slice returns a fresh object reference on each mark/clear. - const hasUnreadActivity = useAppStore((s) => s.unreadTerminalTabs[tab.id] === true) + // Why: agent-completion unread is pane-keyed and exists even when the + // experimental generic terminal-attention setting is off. Collapse both + // sources to one per-tab primitive so unrelated tabs do not re-render. + const hasUnreadActivity = useAppStore( + (s) => + s.unreadTerminalTabs[tab.id] === true || + hasUnreadAgentCompletionForTerminalTab(s.unreadAgentCompletionPanes, tab.id) + ) + // Why: the resolver returns a WorktreeStatus primitive, so unrelated agent + // updates can't repaint this tab. The per-tab pane bucketing it reads is + // memoized once per store snapshot, so this stays O(1) per tab per write. + const activityStatus = useAppStore((s) => + resolveTerminalTabActivityStatus({ + tab, + agentStatusByPaneKey: s.agentStatusByPaneKey, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + terminalLayout: s.terminalLayoutsByTabId?.[tab.id] + }) + ) const renamingTabId = useAppStore((s) => s.renamingTabId) const setRenamingTabId = useAppStore((s) => s.setRenamingTabId) @@ -118,11 +136,11 @@ export default function SortableTab({ const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const [isEditing, setIsEditing] = useState(false) - // Why: single source of truth for the unread-activity visual treatment — - // drives BOTH the amber wash overlay and the bell icon swap below. Kept as - // one derived boolean so the two visual cues can never drift out of sync - // (e.g. showing the bell without the wash, or vice versa). - const showActivityAffordance = hasUnreadActivity && !isEditing + // Why: a live working/needs-input state is newer and more specific than an + // unread event from the prior turn. It owns the icon until the turn ends; + // the unread completion bell then returns if the tab is still unvisited. + const showUnreadActivity = + hasUnreadActivity && !isEditing && !isTerminalTabActivityLive(activityStatus) const [renameValue, setRenameValue] = useState('') const renameFocusFrameRef = useRef(null) // Why: React's synthetic onBlur fires during the Input's unmount when isEditing flips @@ -236,6 +254,7 @@ export default function SortableTab({ // pass even if the tab-bar render path had silently broken (the same // tautology that let PR #1186's render crash ship past E2E in #1193). data-active={isActive ? 'true' : 'false'} + data-agent-activity-status={activityStatus} {...attributes} {...dragListeners} // Why: on unread activity, tint the whole tab with a subtle amber @@ -284,50 +303,18 @@ export default function SortableTab({ }} > {isActive && } - {showActivityAffordance && ( - // Why: amber wash for unread tabs. Rendered as a real DOM child so - // both drop indicators (::before left / ::after right in - // drop-indicator.ts) stay free for drag-and-drop feedback — a prior - // ::after-based implementation collided with the right-edge drop - // indicator and hid it on unread tabs. pointer-events-none keeps - // clicks reaching the underlying tab handlers. + {showUnreadActivity && ( + // Why: a real DOM child leaves both drop-indicator pseudo-elements + // available and keeps pointer events reaching the tab beneath it. )} - {showActivityAffordance ? ( - // Why: the activity marker sits to the LEFT of the tab title using - // Orca's filled bell glyph (amber-500 with a subtle drop shadow) - // so it matches the worktree-level bell in the sidebar — keeping - // every "needs your attention" surface in Orca consistent. - - - - ) : tabAgent ? ( - // Why: coding-agent tabs should read as Claude/Codex/etc. while the - // harness is running; plain shells keep the generic terminal tile. - - - - ) : ( - // Why: ShellIcon renders a colored brand-style tile for PowerShell, - // CMD, Git Bash, and WSL so Windows users can distinguish shells at a glance. - // On mac/linux (or Windows tabs without a resolved shell) it falls - // back to a matching colored generic-terminal tile — keeping every - // tab's leading glyph in the same visual idiom instead of mixing a - // flat lucide chevron with the brand tiles. Opacity dims the icon - // on inactive tabs to match the existing text treatment without - // desaturating the brand colors beyond recognition. - - - - )} + {isPinned && !isEditing && ( )} diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx new file mode 100644 index 00000000000..71508153243 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx @@ -0,0 +1,85 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' +import type { TerminalTabActivityStatus } from './terminal-tab-activity-status' + +/** Render one activity status through the production leading-icon component. */ +function renderStatus(status: TerminalTabActivityStatus): string { + return renderToStaticMarkup( + + ) +} + +describe('TerminalTabLeadingIcon', () => { + it('shows a working spinner beside the provider icon', () => { + const markup = renderStatus('working') + + expect(markup).toContain('data-testid="tab-agent-activity-indicator"') + expect(markup).toContain('data-agent-activity-status="working"') + expect(markup).toContain('aria-label="Working"') + expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') + expect(markup).toContain('data-agent-icon="codex"') + }) + + it('shows completion as an emerald check', () => { + const markup = renderStatus('done') + + expect(markup).toContain('data-agent-activity-status="done"') + expect(markup).toContain('lucide-circle-check') + expect(markup).toContain('text-emerald-500') + expect(markup).toContain('data-agent-icon="codex"') + }) + + it('shows a needs-input (permission) state as an amber dot', () => { + const markup = renderStatus('permission') + + expect(markup).toContain('data-agent-activity-status="permission"') + expect(markup).toContain('bg-amber-500') + expect(markup).not.toContain('bg-red-500') + }) + + it('shows no activity glyph for an active shell — just the identity icon', () => { + const markup = renderStatus('active') + + expect(markup).not.toContain('data-testid="tab-agent-activity-indicator"') + expect(markup).toContain('data-agent-icon="codex"') + }) + + it('falls back to the shell icon when a plain tab is inactive', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-shell-icon="generic"') + expect(markup).not.toContain('data-testid="tab-agent-activity-indicator"') + }) + + it('keeps the unread bell in the icon slot after an unvisited completion', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-testid="tab-activity-bell"') + expect(markup).toContain('aria-label="Unread agent completion"') + expect(markup).toContain('data-agent-icon="codex"') + expect(markup).not.toContain('data-testid="tab-agent-activity-indicator"') + }) +}) diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx new file mode 100644 index 00000000000..5c3b0c7dc24 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx @@ -0,0 +1,114 @@ +import { AgentStateDot, type AgentDotState } from '@/components/AgentStateDot' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import type { TerminalTab, TuiAgent } from '../../../../shared/types' +import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' +import { ShellIcon } from './shell-icons' +import type { TerminalTabActivityStatus } from './terminal-tab-activity-status' + +type TerminalTabLeadingIconProps = { + agent: TuiAgent | null + activityStatus: TerminalTabActivityStatus + shell: TerminalTab['shellOverride'] + showUnreadActivity: boolean + isActive: boolean +} + +type TerminalTabAgentIdentityIconProps = { + agent: TuiAgent + isActive: boolean + className?: string +} + +/** + * Map the container status to the shared state-dot vocabulary. `active` and + * `inactive` carry no activity glyph — the tab falls through to its agent or + * shell identity icon instead. Uses the same WorktreeStatus vocabulary as the + * sidebar so live states read identically (tabs intentionally omit the card's + * retained-done promotion, so a stale green check can differ after cleanup). + */ +function activityDotState(status: TerminalTabActivityStatus): AgentDotState | null { + switch (status) { + case 'working': + return 'working' + case 'permission': + return 'permission' + case 'done': + return 'done' + default: + return null + } +} + +/** Keep the provider glyph treatment identical across every terminal-tab state. */ +function TerminalTabAgentIdentityIcon({ + agent, + isActive, + className +}: TerminalTabAgentIdentityIconProps): React.JSX.Element { + return ( + + + + ) +} + +/** Render a terminal tab's current state without hiding its agent or shell identity. */ +export function TerminalTabLeadingIcon({ + agent, + activityStatus, + shell, + showUnreadActivity, + isActive +}: TerminalTabLeadingIconProps): React.JSX.Element { + if (showUnreadActivity) { + return ( + + + {agent ? : null} + + ) + } + + const dotState = activityDotState(activityStatus) + if (dotState) { + return ( + + + {/* Why: status and identity answer different questions. Keep the agent + logo beside the state glyph so parallel tabs remain scannable. */} + {agent ? : null} + + ) + } + + if (agent) { + return ( + + ) + } + + // Why: ShellIcon renders a colored brand-style tile for PowerShell, CMD, + // Git Bash, and WSL while retaining the generic terminal fallback elsewhere. + return ( + + + + ) +} diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts new file mode 100644 index 00000000000..9dc16e8f70b --- /dev/null +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import { + hasUnreadAgentCompletionForTerminalTab, + resetTerminalTabActivityFlagsCacheForTest, + resolveTerminalTabActivityStatus +} from './terminal-tab-activity-status' + +const TAB_ID = 'tab-1' +const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const NOW = 10_000 + +const TAB: Pick = { id: TAB_ID, title: 'Codex' } + +/** Build a canonical pane-status fixture for one tab leaf. */ +function entry( + leafId: string, + state: AgentStatusEntry['state'], + overrides: Partial = {} +): AgentStatusEntry { + const paneKey = `${TAB_ID}:${leafId}` + return { + paneKey, + state, + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'codex', + ...overrides + } +} + +/** One live PTY for the tab so title/liveness gates pass. */ +const LIVE_PTY = { [TAB_ID]: ['pty-1'] } + +beforeEach(() => { + resetTerminalTabActivityFlagsCacheForTest() + vi.useFakeTimers() + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() + resetTerminalTabActivityFlagsCacheForTest() +}) + +describe('resolveTerminalTabActivityStatus', () => { + it('reports a fresh hook working state', () => { + const working = entry(FIRST_LEAF_ID, 'working') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [working.paneKey]: working }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + + it('lets a needs-input pane outrank a working sibling', () => { + const working = entry(FIRST_LEAF_ID, 'working') + const waiting = entry(SECOND_LEAF_ID, 'waiting') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { + [working.paneKey]: working, + [waiting.paneKey]: waiting + }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('permission') + }) + + it('reports a completed turn as done', () => { + const done = entry(FIRST_LEAF_ID, 'done') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [done.paneKey]: done }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('done') + }) + + it('treats an interrupted done as done, matching the worktree card', () => { + const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [interrupted.paneKey]: interrupted }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('done') + }) + + it('falls back to a live working title when hook status is stale', () => { + const stale = entry(FIRST_LEAF_ID, 'done', { updatedAt: 0 }) + vi.setSystemTime(31 * 60 * 1000) + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'Codex working' }, + agentStatusByPaneKey: { [stale.paneKey]: stale }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + + it('does not treat a preserved title on a sleeping tab as activity', () => { + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'Codex working' }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working' } }, + ptyIdsByTabId: { [TAB_ID]: [] } + }) + ).toBe('inactive') + }) + + it('reads a needs-input hook as permission', () => { + const blocked = entry(FIRST_LEAF_ID, 'blocked') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [blocked.paneKey]: blocked }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('permission') + }) + + it('reads a legacy numeric pane key, matching the sidebar summary', () => { + const working = entry(FIRST_LEAF_ID, 'working', { paneKey: `${TAB_ID}:3` }) + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [working.paneKey]: working }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + + it('reports a live shell with no agent as active (no activity glyph)', () => { + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'zsh' }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('active') + }) +}) + +describe('hasUnreadAgentCompletionForTerminalTab', () => { + it('matches unread completion panes to their owning tab', () => { + expect( + hasUnreadAgentCompletionForTerminalTab( + { + [`${TAB_ID}:${FIRST_LEAF_ID}`]: true, + [`tab-2:${SECOND_LEAF_ID}`]: true + }, + TAB_ID + ) + ).toBe(true) + }) + + it('ignores completion panes owned by other tabs', () => { + expect( + hasUnreadAgentCompletionForTerminalTab({ [`tab-2:${SECOND_LEAF_ID}`]: true }, TAB_ID) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts new file mode 100644 index 00000000000..2e51d42e1d5 --- /dev/null +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -0,0 +1,159 @@ +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' + +// Why: a terminal tab is a container of panes, exactly like a worktree card is +// a container of tabs. Reuse the WorktreeCard status vocabulary and resolver so +// the tab's live states resolve identically to the sidebar (tabs intentionally +// skip the card's retained-done promotion — see resolveTerminalTabActivityStatus). +export type TerminalTabActivityStatus = WorktreeStatus + +// Per-tab live-hook flags, mirroring applyLiveAgentState in +// worktree-agent-activity-summary.ts. blocked/waiting collapse to permission, +// matching every other status surface in the app. +type TerminalTabActivityFlags = { + hasPermission: boolean + hasLiveWorking: boolean + hasLiveDone: boolean + paneIds: Set +} + +type FlagsCache = { + agentStatusByPaneKey: Record | undefined + flagsByTabId: Map +} + +// Why: Zustand reruns every tab's selector on each store write. Bucketing the +// full pane-status map by tab once per snapshot keeps the cost O(agents + tabs) +// instead of O(agents * tabs) — the same memo strategy the sidebar summaries +// use (worktree-agent-activity-summary.ts / worktree-agent-row-selectors.ts). +let flagsCache: FlagsCache | null = null + +function getTerminalTabActivityFlags( + agentStatusByPaneKey: Record | undefined +): Map { + if (flagsCache && flagsCache.agentStatusByPaneKey === agentStatusByPaneKey) { + return flagsCache.flagsByTabId + } + + const flagsByTabId = new Map() + const now = Date.now() + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey ?? {})) { + const identity = parseAgentStatusPaneKey(entry.paneKey || paneKey) + // Why: stale hook entries (>30m) are not authority; a slept/abandoned pane + // must not keep a tab spinning. Same freshness gate as the sidebar. + if (!identity || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + continue + } + + let flags = flagsByTabId.get(identity.tabId) + if (!flags) { + flags = { + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: false, + paneIds: new Set() + } + flagsByTabId.set(identity.tabId, flags) + } + flags.paneIds.add(identity.paneId) + if (entry.state === 'blocked' || entry.state === 'waiting') { + flags.hasPermission = true + } else if (entry.state === 'working') { + flags.hasLiveWorking = true + } else if (entry.state === 'done') { + // Why: an interrupted `done` still reads as completed here, matching the + // WorktreeCard dot (resolveWorktreeStatus has no interrupted state); only + // the smart-sort ordering treats interrupts as idle. + flags.hasLiveDone = true + } + } + + flagsCache = { agentStatusByPaneKey, flagsByTabId } + return flagsByTabId +} + +// Why: mirror the sidebar summary's parse — live entries on restored/imported +// sessions can still carry pre-UUID numeric pane keys. Keep the numeric pane id +// so the title-heuristic dedup in resolveWorktreeStatus can still match them. +function parseAgentStatusPaneKey(paneKey: string): { tabId: string; paneId: string } | null { + const parsed = parsePaneKey(paneKey) + if (parsed) { + return { tabId: parsed.tabId, paneId: parsed.leafId } + } + const legacy = parseLegacyNumericPaneKey(paneKey) + return legacy ? { tabId: legacy.tabId, paneId: legacy.numericPaneId } : null +} + +const EMPTY_PANE_IDS: ReadonlySet = new Set() + +type TerminalTabActivityInput = { + tab: Pick + agentStatusByPaneKey?: Record + runtimePaneTitlesByTabId?: Record> + ptyIdsByTabId?: Record + terminalLayout?: TerminalLayoutSnapshot +} + +/** + * Resolve a terminal tab's status glyph through the canonical WorktreeCard + * resolver. Fresh hook state is authoritative per pane; hookless-but-live panes + * fall back to the same title heuristic used by the sidebar and smart sort. + * Returns a `WorktreeStatus` primitive so the tab re-renders only when it flips. + */ +export function resolveTerminalTabActivityStatus({ + tab, + agentStatusByPaneKey, + runtimePaneTitlesByTabId, + ptyIdsByTabId, + terminalLayout +}: TerminalTabActivityInput): TerminalTabActivityStatus { + const flags = getTerminalTabActivityFlags(agentStatusByPaneKey).get(tab.id) + return resolveWorktreeStatus({ + tabs: [tab], + browserTabs: [], + ptyIdsByTabId: ptyIdsByTabId ?? {}, + runtimePaneTitlesByTabId: runtimePaneTitlesByTabId ?? {}, + agentStatusPaneIdsByTabId: { [tab.id]: flags?.paneIds ?? EMPTY_PANE_IDS }, + terminalLayoutsByTabId: terminalLayout ? { [tab.id]: terminalLayout } : undefined, + hasPermission: flags?.hasPermission ?? false, + hasLiveWorking: flags?.hasLiveWorking ?? false, + hasLiveDone: flags?.hasLiveDone ?? false, + // Why: retained/orchestration promotions are worktree-aggregate concerns; + // a tab reflects its own live panes and title only. + hasRetainedDone: false + }) +} + +/** True while the tab shows a live in-turn signal (spinner or needs-input). */ +export function isTerminalTabActivityLive(status: TerminalTabActivityStatus): boolean { + return status === 'working' || status === 'permission' +} + +/** Match pane-level unread completion markers to their owning terminal tab. */ +export function hasUnreadAgentCompletionForTerminalTab( + unreadAgentCompletionPanes: Record | undefined, + tabId: string +): boolean { + for (const paneKey of Object.keys(unreadAgentCompletionPanes ?? {})) { + // paneKey is `${tabId}:${leafId}` and tab ids never contain ":", so the + // prefix up to the first ":" is the owning tab id (see + // selectFloatingWorkspaceHasUnread). Prefix-match to keep legacy keys. + const separatorIndex = paneKey.indexOf(':') + const owningTabId = separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) + if (owningTabId === tabId) { + return true + } + } + return false +} + +/** Test-only: clear the memoized per-tab flag cache between cases. */ +export function resetTerminalTabActivityFlagsCacheForTest(): void { + flagsCache = null +}