fix(agent-status): cover secondary background presentations

Carry the background-only marker through dashboard snapshots and live popout patches so the board, map, and notes target menu do not retain a foreground spinner. Keep the agent state and dashboard grouping working for liveness.
This commit is contained in:
Brennan Benson
2026-08-14 15:59:29 -07:00
parent 84506177bf
commit ac9542b1c9
14 changed files with 172 additions and 23 deletions
@@ -99,6 +99,19 @@ function imageIconSrc(bodyBytes: number, withWhitespace = false): string {
describe('dashboard payload validation', () => {
it('accepts a complete dashboard snapshot', () => {
expect(isDashboardSnapshot(SNAPSHOT)).toBe(true)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [
{
...SNAPSHOT.cards[0],
bucket: 'working',
dotState: 'working',
backgroundOnly: true
}
]
})
).toBe(true)
})
it('rejects malformed or unbounded snapshot fields', () => {
@@ -109,6 +122,18 @@ describe('dashboard payload validation', () => {
cards: [{ ...SNAPSHOT.cards[0], bucket: 'unexpected' }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], backgroundOnly: 'true' }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], backgroundOnly: true }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
@@ -256,6 +256,8 @@ function isDashboardCard(value: unknown): boolean {
DASHBOARD_BUCKETS.has(card.bucket) &&
typeof card.dotState === 'string' &&
DASHBOARD_DOT_STATES.has(card.dotState) &&
(card.backgroundOnly === undefined ||
(card.backgroundOnly === true && card.dotState === 'working')) &&
isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) &&
isOptionalBoundedString(card.lastUserMessage, AGENT_STATUS_MAX_FIELD_LENGTH) &&
isOptionalBoundedString(card.lastAgentMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) &&
@@ -19,7 +19,9 @@ vi.mock('@/lib/agent-catalog', () => ({
}))
vi.mock('@/components/AgentStateDot', () => ({
AgentStateDot: () => <span data-testid="state-dot" />
AgentStateDot: ({ state }: { state: string }) => (
<span data-testid="state-dot" data-state={state} />
)
}))
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
@@ -78,6 +80,23 @@ describe('AgentKanbanCard', () => {
expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument()
})
it('renders background-only work without an active foreground spinner', () => {
const { rerender } = renderCard({ card: card(), now: 2_000 })
expect(screen.getByTestId('state-dot')).toHaveAttribute('data-state', 'working')
rerender(
<TooltipProvider>
<AgentKanbanCard
card={card({ backgroundOnly: true })}
now={2_000}
onOpenTerminal={vi.fn()}
/>
</TooltipProvider>
)
expect(screen.getByTestId('state-dot')).toHaveAttribute('data-state', 'background')
})
it('shows the question glyph once when a summary is available', () => {
const attentionCard = card({
bucket: 'attention',
@@ -87,6 +87,7 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean {
a.agentType === b.agentType &&
a.bucket === b.bucket &&
a.dotState === b.dotState &&
a.backgroundOnly === b.backgroundOnly &&
a.task === b.task &&
a.lastUserMessage === b.lastUserMessage &&
a.lastAgentMessage === b.lastAgentMessage &&
@@ -208,6 +209,8 @@ export const AgentKanbanCard = memo(
// it". Everything else stays neutral so the tint keeps meaning something.
const needsYou = card.bucket === 'attention'
const displayState = dashboardCardDisplayState(card)
const dotState =
card.backgroundOnly === true && displayState === 'working' ? 'background' : displayState
const isDone = displayState === 'done'
// Why: the session's own name heads the card. Without one the worktree is
// the best heading left — and then the footer drops it rather than say it
@@ -248,7 +251,7 @@ export const AgentKanbanCard = memo(
>
{heading}
</span>
{card.askSummary ? null : <AgentStateDot state={displayState} className="ml-auto" />}
{card.askSummary ? null : <AgentStateDot state={dotState} className="ml-auto" />}
</div>
{card.lastUserMessage || card.lastAgentMessage ? (
@@ -18,6 +18,7 @@ import { AgentMapQuestionMarker } from './AgentMapQuestionMarker'
import type { AgentMapFlareStatus } from './agent-map-node-metadata'
import {
agentMapAttentionMarkerScale,
agentMapDotState,
agentMapStatusLabel,
agentName,
formatDuration,
@@ -111,13 +112,10 @@ function WorktreeDetails({
{agentName(agent.card)}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{agentMapStatusLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
{agentMapStatusLabel(agent)} · {formatDuration(agent.durationMinutes)}
</span>
</span>
<AgentStateDot
state={agent.status === 'done-seen' ? 'done' : agent.status}
size="md"
/>
<AgentStateDot state={agentMapDotState(agent)} size="md" />
</button>
))
)}
@@ -321,7 +319,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
tabIndex={agentExiting ? -1 : 0}
aria-hidden={agentExiting || undefined}
aria-pressed={selectedPaneKey === agent.card.paneKey}
aria-label={`${agentName(agent.card)}, ${agentMapStatusLabel(agent.status)}${agent.card.unseen ? ', unread' : ''}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`}
aria-label={`${agentName(agent.card)}, ${agentMapStatusLabel(agent)}${agent.card.unseen ? ', unread' : ''}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`}
className={`agent-map-agent-node fleet-status-${agent.status}${selectedPaneKey === agent.card.paneKey ? ' is-selected' : ''}${agent.motionState ? ` is-${agent.motionState}` : ''}`}
transform={`translate(${agent.x} ${agent.y})`}
onClick={(event) => {
@@ -1,17 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { agentMapStatusLabel } from './agent-map-node-presentation'
import { agentMapDotState, agentMapStatusLabel } from './agent-map-node-presentation'
import type { AgentMapAgentNode } from './agent-map-layout'
vi.mock('@/i18n/i18n', () => ({
translate: (key: string, fallback: string) => `${key}:${fallback}`
}))
describe('agentMapStatusLabel', () => {
function node(status: AgentMapAgentNode['status'], backgroundOnly = false): AgentMapAgentNode {
return {
status,
card: backgroundOnly ? { backgroundOnly: true } : {}
} as AgentMapAgentNode
}
it('localizes the map-only acknowledged completion state', () => {
expect(agentMapStatusLabel('done-seen')).toBe('dashboardPopout.map.status.doneSeen:Done, seen')
expect(agentMapStatusLabel(node('done-seen'))).toBe(
'dashboardPopout.map.status.doneSeen:Done, seen'
)
})
it('keeps shared agent states on their existing labels', () => {
expect(agentMapStatusLabel('working')).toBe('Working')
expect(agentMapStatusLabel('done')).toBe('Done')
expect(agentMapStatusLabel(node('working'))).toBe('Working')
expect(agentMapStatusLabel(node('done'))).toBe('Done')
})
it('presents a background-only working node without changing its map status', () => {
const background = node('working', true)
expect(agentMapDotState(background)).toBe('background')
expect(agentMapStatusLabel(background)).toBe('Background work')
expect(background.status).toBe('working')
})
})
@@ -1,19 +1,25 @@
import { agentStateLabel } from '@/components/AgentStateDot'
import { agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
import { translate } from '@/i18n/i18n'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path'
import type { AgentMapAgentNode } from './agent-map-layout'
import type { AgentMapNodeStatus } from './agent-map-node-metadata'
/** Lives here, not in `agent-map-node-metadata`: `agentStateLabel` drags in React and
* lucide-react, and that module is on the layout and filter paths, which must stay
* free of component imports. `agentStateLabel` is shared with every other dot
* renderer, so the map's extra state gets its label here rather than widening
* `AgentDotState`. */
export function agentMapStatusLabel(status: AgentMapNodeStatus): string {
return status === 'done-seen'
export function agentMapDotState(node: AgentMapAgentNode): AgentDotState {
if (node.card.backgroundOnly === true && node.status === 'working') {
return 'background'
}
return node.status === 'done-seen' ? 'done' : node.status
}
export function agentMapStatusLabel(node: AgentMapAgentNode): string {
return node.status === 'done-seen'
? translate('dashboardPopout.map.status.doneSeen', 'Done, seen')
: agentStateLabel(status)
: agentStateLabel(agentMapDotState(node))
}
export function formatDuration(minutes: number): string {
@@ -86,6 +86,25 @@ describe('patchDashboardSnapshotFromAgentStatus', () => {
})
})
it('updates background-only presentation without changing the working bucket', () => {
const marked = patchDashboardSnapshotFromAgentStatus(
snapshot(),
event({ state: 'working', backgroundOnly: true })
).snapshot.cards[0]
expect(marked).toMatchObject({
bucket: 'working',
dotState: 'working',
backgroundOnly: true
})
const cleared = patchDashboardSnapshotFromAgentStatus(
snapshot([{ ...marked, statusUpdatedAt: 300 }]),
event({ state: 'working', backgroundOnly: undefined, receivedAt: 301 })
).snapshot.cards[0]
expect(cleared.backgroundOnly).toBeUndefined()
})
it('ignores stale, wrong-workspace, and session-only events', () => {
const original = snapshot()
expect(
@@ -6,6 +6,7 @@ import {
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket'
import { isBackgroundOnlyAgentActivity } from '../../../../shared/agent-background-only-activity'
export type DashboardAgentStatusPatchResult = {
matched: boolean
@@ -62,6 +63,7 @@ export function patchDashboardSnapshotFromAgentStatus(
: {}),
bucket,
dotState,
backgroundOnly: isBackgroundOnlyAgentActivity(event) || undefined,
unseen,
stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt,
statusUpdatedAt: event.receivedAt,
@@ -200,6 +200,23 @@ describe('buildDashboardSnapshot', () => {
expect(card.unseen).toBe(true)
})
it('preserves background-only presentation without moving the live card', () => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
[PANE_KEY]: entry({ state: 'working', backgroundOnly: true })
}
}),
NOW
)
expect(snapshot.cards[0]).toMatchObject({
bucket: 'working',
dotState: 'working',
backgroundOnly: true
})
})
it('publishes terminal-backed orchestrated workers under their direct parent', () => {
const snapshot = buildDashboardSnapshot(
baseState({
@@ -56,6 +56,7 @@ import {
} from './dashboard-worktree-launch-options'
import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options'
import { dashboardBucketForDotState } from './dashboard-card-bucket'
import { isBackgroundOnlyAgentActivity } from '../../../../shared/agent-background-only-activity'
/** The store slices the snapshot builder reads. Kept as a Pick so unit tests
* can pass a partial store without constructing the whole AppState. */
@@ -255,7 +256,6 @@ export function buildDashboardSnapshot(
osRelease: clientHost.osRelease
})
: null
const finishedAt = lastEnteredDoneAt(row)
const hostMetadata = includeCardDetails
? dashboardCardMapWorkspaceMetadata(
workspace,
@@ -266,13 +266,13 @@ export function buildDashboardSnapshot(
: undefined
// Only repos that actually contribute a card ship their icon.
repoIconsByRepoId[workspace.projectId] = workspace.repoIcon
cards.push({
paneKey: row.paneKey,
ptyId,
agentType: row.agentType,
bucket,
dotState,
...(isBackgroundOnlyAgentActivity(row.entry) ? { backgroundOnly: true } : {}),
task: isTitleDerived ? '' : rowTask(row),
repoId: workspace.projectId,
worktreeId,
@@ -296,7 +296,7 @@ export function buildDashboardSnapshot(
lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt),
lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage),
startedAt: row.startedAt,
finishedAt,
finishedAt: lastEnteredDoneAt(row),
stateChangedAt: row.entry.stateStartedAt || row.startedAt,
statusUpdatedAt: row.entry.updatedAt,
// Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the
@@ -118,6 +118,8 @@ vi.mock('@/components/AgentStateDot', () => ({
switch (state) {
case 'working':
return 'Working'
case 'background':
return 'Background work'
case 'blocked':
return 'Blocked'
case 'waiting':
@@ -193,7 +195,8 @@ function agentRow({
title,
agentType,
state = 'done',
startedAt = harness.now
startedAt = harness.now,
backgroundOnly = false
}: {
paneKey: string
tabId: string
@@ -201,11 +204,15 @@ function agentRow({
agentType: TuiAgent
state?: AgentStatusState | 'idle'
startedAt?: number
backgroundOnly?: boolean
}): DashboardAgentRowData {
const entryState: AgentStatusState = state === 'idle' ? 'working' : state
return {
paneKey,
entry: agentEntry(paneKey, agentType, entryState, startedAt),
entry: {
...agentEntry(paneKey, agentType, entryState, startedAt),
...(backgroundOnly ? { backgroundOnly: true } : {})
},
tab: tab(tabId, { title }) as DashboardAgentRowData['tab'],
agentType,
state,
@@ -450,6 +457,36 @@ describe('ReviewNotesSendMenuContent', () => {
expect(collectText(items[1])).toContain('Claude')
})
it('labels a background-only target without foreground working presentation', () => {
const paneKey = makePaneKey(TAB_A, LEAF_A)
harness.worktreeAgentRows = [
agentRow({
paneKey,
tabId: TAB_A,
title: 'Background session',
agentType: 'claude',
state: 'working',
backgroundOnly: true
})
]
harness.noteTargets = [
{
paneKey,
tabId: TAB_A,
leafId: LEAF_A,
agentType: 'claude',
tabTitle: 'Background session',
status: 'eligible'
}
]
const item = findByType(render(), 'DropdownMenuItem')
const dot = findByType(item, 'AgentStateDot')
expect(dot.props.state).toBe('background')
expect(collectText(item)).toContain('Background work')
})
it('does not target title-detected rows skipped by target derivation', async () => {
const paneKey = makePaneKey(TAB_B, LEAF_B)
harness.worktreeAgentRows = [
@@ -34,6 +34,7 @@ import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import { translate } from '@/i18n/i18n'
import { getAgentDotState } from '@/components/sidebar/worktree-card-agent-summary'
type OrderedSendTarget = {
target: NotesSendAgentTarget
@@ -245,7 +246,7 @@ function AgentTargetMenuItem({
onSend: (target: NotesSendAgentTarget) => void
}): React.JSX.Element {
const tabTitle = target.tabTitle.trim()
const state = asDotState(agent?.state ?? 'idle')
const state = agent ? getAgentDotState(agent) : asDotState('idle')
const timeAgo = agent ? formatAgentRelativeTime(agent, now) : null
const secondaryParts = [
agentStateLabel(state),
+3
View File
@@ -84,6 +84,9 @@ export type DashboardCard = {
agentType: AgentType
bucket: DashboardBucket
dotState: DashboardCardDotState
/** Presentation-only marker for a finished foreground turn whose registered
* background work keeps the card in the working bucket. */
backgroundOnly?: true
/** One-line task/prompt text shown on the card. */
task: string
/** The most recent message the user sent this agent (its current prompt). */