diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 83970cfa4ca..89d67d96ac9 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -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, diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 6186e638fd1..df2a0c002ba 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -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) && diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index d98592232c0..ac3ae149bef 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -19,7 +19,9 @@ vi.mock('@/lib/agent-catalog', () => ({ })) vi.mock('@/components/AgentStateDot', () => ({ - AgentStateDot: () => + AgentStateDot: ({ state }: { state: string }) => ( + + ) })) function card(overrides: Partial = {}): 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( + + + + ) + + 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', diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index faef58dc7b4..d95128e68ca 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -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} - {card.askSummary ? null : } + {card.askSummary ? null : } {card.lastUserMessage || card.lastAgentMessage ? ( diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index f4999a012bd..a29a3680564 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -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)} - {agentMapStatusLabel(agent.status)} · {formatDuration(agent.durationMinutes)} + {agentMapStatusLabel(agent)} · {formatDuration(agent.durationMinutes)} - + )) )} @@ -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) => { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts index f02837497cb..5afd4a30ad9 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts @@ -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') }) }) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.ts index 78910f64865..6c01465119e 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.ts @@ -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 { diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts index 98f9ec369c5..e77c1cfbef5 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts @@ -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( diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts index 816474eb70f..fa8676c3ec5 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -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, diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index 884a8ebcaf9..6fdcd0af7a9 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -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({ diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 53e959f489c..679a20ba8fb 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -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 diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 1ae74985923..259102abcf1 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -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 = [ diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index adb0c3d1ed8..750e299b81b 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -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), diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index a3a888d820f..a0b18a47c1d 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -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). */