mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user