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 <help@stably.ai>

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
gatsby74
2026-07-13 18:32:08 -07:00
committed by GitHub
co-authored by Orca Jinjing
parent 9b092fd129
commit 67fdb1c2b9
7 changed files with 575 additions and 56 deletions
@@ -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')
})
@@ -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
)}
/>
@@ -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<number | null>(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 && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{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.
<span aria-hidden className="pointer-events-none absolute inset-0 bg-amber-500/10" />
)}
{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.
<span data-testid="tab-activity-bell" className="inline-flex shrink-0">
<FilledBellIcon className="w-3 h-3 mr-1 text-amber-500 drop-shadow-sm" />
</span>
) : tabAgent ? (
// Why: coding-agent tabs should read as Claude/Codex/etc. while the
// harness is running; plain shells keep the generic terminal tile.
<span
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
data-agent-icon={tabAgent}
aria-hidden
>
<AgentIcon agent={tabAgent} size={12} />
</span>
) : (
// 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.
<span
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
data-shell-icon={shellForIcon ?? 'generic'}
aria-hidden
>
<ShellIcon shell={shellForIcon} size={12} />
</span>
)}
<TerminalTabLeadingIcon
agent={tabAgent}
activityStatus={activityStatus}
shell={shellForIcon}
showUnreadActivity={showUnreadActivity}
isActive={isActive}
/>
{isPinned && !isEditing && (
<Pin className="mr-1 size-3 shrink-0 text-muted-foreground" aria-hidden />
)}
@@ -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(
<TerminalTabLeadingIcon
agent="codex"
activityStatus={status}
shell={undefined}
showUnreadActivity={false}
isActive={false}
/>
)
}
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(
<TerminalTabLeadingIcon
agent={null}
activityStatus="inactive"
shell={undefined}
showUnreadActivity={false}
isActive={false}
/>
)
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(
<TerminalTabLeadingIcon
agent="codex"
activityStatus="done"
shell={undefined}
showUnreadActivity={true}
isActive={false}
/>
)
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"')
})
})
@@ -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 (
<span
className={cn('inline-flex', !isActive && 'opacity-70', className)}
data-agent-icon={agent}
aria-hidden
>
<AgentIcon agent={agent} size={12} />
</span>
)
}
/** 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 (
<span
data-testid="tab-activity-bell"
aria-label="Unread agent completion"
className="mr-1 inline-flex shrink-0 items-center gap-1"
>
<FilledBellIcon className="size-3 text-amber-500 drop-shadow-sm" />
{agent ? <TerminalTabAgentIdentityIcon agent={agent} isActive={isActive} /> : null}
</span>
)
}
const dotState = activityDotState(activityStatus)
if (dotState) {
return (
<span
data-testid="tab-agent-activity-indicator"
data-agent-activity-status={activityStatus}
className="mr-1 inline-flex shrink-0 items-center gap-1"
>
<AgentStateDot state={dotState} size="md" />
{/* Why: status and identity answer different questions. Keep the agent
logo beside the state glyph so parallel tabs remain scannable. */}
{agent ? <TerminalTabAgentIdentityIcon agent={agent} isActive={isActive} /> : null}
</span>
)
}
if (agent) {
return (
<TerminalTabAgentIdentityIcon agent={agent} isActive={isActive} className="mr-1 shrink-0" />
)
}
// Why: ShellIcon renders a colored brand-style tile for PowerShell, CMD,
// Git Bash, and WSL while retaining the generic terminal fallback elsewhere.
return (
<span
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
data-shell-icon={shell ?? 'generic'}
aria-hidden
>
<ShellIcon shell={shell} size={12} />
</span>
)
}
@@ -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<TerminalTab, 'id' | 'title'> = { 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> = {}
): 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)
})
})
@@ -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<string>
}
type FlagsCache = {
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
flagsByTabId: Map<string, TerminalTabActivityFlags>
}
// 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<string, AgentStatusEntry> | undefined
): Map<string, TerminalTabActivityFlags> {
if (flagsCache && flagsCache.agentStatusByPaneKey === agentStatusByPaneKey) {
return flagsCache.flagsByTabId
}
const flagsByTabId = new Map<string, TerminalTabActivityFlags>()
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<string> = new Set()
type TerminalTabActivityInput = {
tab: Pick<TerminalTab, 'id' | 'title'>
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
ptyIdsByTabId?: Record<string, string[]>
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<string, true> | 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
}