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). */