From 8fbe9ecd3689ee7fa7995f32598115da6b34e8e7 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 23 Sep 2026 13:53:09 -0700
Subject: [PATCH 01/11] test(sidebar): pin today's subagent rows and chat strip
rows from legacy shapes
Captured on the unmodified renderer: the compact and full sidebar child rows built from a
legacy subagents snapshot, and the expanded chat strip built from a legacy background-task
roster. A host that sends only these shapes must keep rendering exactly these rows.
---
...nt-child-row-fallback-pixels.test.tsx.snap | 46 ++++
...ubagent-child-row-fallback-pixels.test.tsx | 237 ++++++++++++++++++
2 files changed, 283 insertions(+)
create mode 100644 src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap
create mode 100644 src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx
diff --git a/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap b/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap
new file mode 100644
index 000000000000..42013059c736
--- /dev/null
+++ b/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap
@@ -0,0 +1,46 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`chat strip rows from a legacy background-task roster > render exactly as before 1`] = `"
8 agents · 1 shell · 1 monitor "`;
+
+exports[`chat strip rows from a legacy background-task roster > render exactly as before 2`] = `"8 agents · 1 shell · 1 monitor "`;
+
+exports[`sidebar child rows from a legacy subagents snapshot > render a stale parent and a lost transport exactly as before 1`] = `
+[
+ "Review the parser - No update in 5m claude-sonnet 10m ",
+ "Explore - No update in 5m 8m ",
+ "No recent update - No update in 5m 16m ",
+ "Approve edits - No update in 5m 6m ",
+ "Review the parser - No update in 5m claude-sonnet 10m ",
+]
+`;
+
+exports[`sidebar child rows from a legacy subagents snapshot > render the compact rows exactly as before 1`] = `
+[
+ "Review the parser - general-purpose claude-sonnet 10m
",
+ "Explore 8m
",
+ "Working - Agent 16m
",
+ "",
+ "Rate limited - Agent 6m
",
+ "Teammate - writer 6m
",
+ "Lost child - No update in 5m 6m ",
+]
+`;
+
+exports[`sidebar child rows from a legacy subagents snapshot > render the full rows exactly as before 1`] = `
+[
+ "Review the parser claude-sonnet 10m ago
",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "Lost child No update in 5m
",
+]
+`;
+
+exports[`sidebar child rows from a legacy subagents snapshot > render the full rows exactly as before 2`] = `
+[
+ "Review the parser claude-sonnet No update in 5m
",
+ "",
+]
+`;
diff --git a/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx
new file mode 100644
index 000000000000..0be9f5ae5469
--- /dev/null
+++ b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx
@@ -0,0 +1,237 @@
+/** @vitest-environment happy-dom */
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
+import type { AgentStatusEntry, AgentSubagentSnapshot } from '../../../../shared/agent-status-types'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow'
+import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { CompactAgentRow } from './worktree-card-compact-agent-row'
+import { buildSubagentChildRows } from './worktree-subagent-child-rows'
+
+vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({
+ useAgentRowConversationName: () => null
+}))
+
+vi.mock('./CacheTimer', () => ({
+ default: () => null,
+ usePromptCacheCountdownForPane: () => null
+}))
+
+// Captured on the unmodified renderer: a host that sends only today's legacy shapes (an old host,
+// or any CLI pane) must keep rendering exactly these rows.
+
+const NOW = 1_000_000
+
+const tab: TerminalTab = {
+ id: 'parent-tab',
+ ptyId: null,
+ worktreeId: 'wt-1',
+ title: 'Parent',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+}
+
+function parentWith(
+ subagents: AgentSubagentSnapshot[],
+ overrides: Partial = {}
+): AgentStatusEntry {
+ return {
+ paneKey: 'parent-tab:leaf-1',
+ tabId: tab.id,
+ worktreeId: tab.worktreeId,
+ state: 'working',
+ prompt: 'parent prompt',
+ updatedAt: 700_000,
+ stateStartedAt: 10,
+ stateHistory: [],
+ subagents,
+ ...overrides
+ }
+}
+
+const SUBAGENTS: AgentSubagentSnapshot[] = [
+ {
+ id: 'described',
+ state: 'working',
+ startedAt: 400_000,
+ agentType: 'general-purpose',
+ model: 'claude-sonnet',
+ description: 'Review the parser'
+ },
+ { id: 'typed-only', state: 'working', startedAt: 500_000, agentType: 'Explore' },
+ { id: 'anonymous', state: 'working', startedAt: 0 },
+ { id: 'asking', state: 'waiting', startedAt: 600_000, description: 'Approve edits' },
+ { id: 'stuck', state: 'blocked', startedAt: 610_000, description: 'Rate limited' },
+ { id: 'parked', state: 'idle', startedAt: 620_000, description: 'Teammate', agentType: 'writer' },
+ { id: 'quiet', state: 'unverifiable', startedAt: 630_000, description: 'Lost child' }
+]
+
+function normalize(markup: string): string {
+ return markup.replaceAll('', '')
+}
+
+function compactMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string[] {
+ return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) =>
+ normalize(
+ renderToStaticMarkup(
+
+ {}} />
+
+ )
+ )
+ )
+}
+
+function fullMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string[] {
+ return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) =>
+ normalize(
+ renderToStaticMarkup(
+
+ {}}
+ onDismiss={() => {}}
+ stateDotSize="sm"
+ hideExpand
+ hideLineageConnectors
+ />
+
+ )
+ )
+ )
+}
+
+function textOf(markup: string): string {
+ const container = document.createElement('div')
+ container.innerHTML = markup
+ return container.textContent ?? ''
+}
+
+describe('sidebar child rows from a legacy subagents snapshot', () => {
+ it('render the compact rows exactly as before', () => {
+ const rows = compactMarkup(parentWith(SUBAGENTS), true)
+ expect(rows.map(textOf)).toEqual([
+ 'Review the parser - general-purposeclaude-sonnet10m',
+ 'Explore8m',
+ 'Working - Agent16m',
+ 'Approve edits - Agent6m',
+ 'Rate limited - Agent6m',
+ 'Teammate - writer6m',
+ 'Lost child - No update in 5m6m'
+ ])
+ expect(rows).toMatchSnapshot()
+ })
+
+ it('render a stale parent and a lost transport exactly as before', () => {
+ const stale = compactMarkup(parentWith(SUBAGENTS.slice(0, 4)), false)
+ const lost = compactMarkup(
+ parentWith(SUBAGENTS.slice(0, 1), { subagentObservation: 'unverifiable' }),
+ true
+ )
+ expect([...stale, ...lost].map(textOf)).toEqual([
+ 'Review the parser - No update in 5mclaude-sonnet10m',
+ 'Explore - No update in 5m8m',
+ 'No recent update - No update in 5m16m',
+ 'Approve edits - No update in 5m6m',
+ 'Review the parser - No update in 5mclaude-sonnet10m'
+ ])
+ expect([...stale, ...lost]).toMatchSnapshot()
+ })
+
+ it('render the full rows exactly as before', () => {
+ expect(fullMarkup(parentWith(SUBAGENTS), true)).toMatchSnapshot()
+ expect(fullMarkup(parentWith(SUBAGENTS.slice(0, 2)), false)).toMatchSnapshot()
+ })
+})
+
+const TASKS: AgentSessionBackgroundTask[] = [
+ {
+ id: 'agent-working',
+ kind: 'agent',
+ description: 'Review the parser',
+ name: 'general-purpose',
+ state: 'working',
+ startedAt: 400_000,
+ totalTokens: 18_200
+ },
+ { id: 'agent-waiting', kind: 'agent', description: 'Approve edits', state: 'waiting' },
+ { id: 'agent-blocked', kind: 'agent', name: 'Explore', state: 'blocked', startedAt: 500_000 },
+ { id: 'agent-quiet', kind: 'agent', description: 'subagent', state: 'unverifiable' },
+ { id: 'agent-stateless', kind: 'agent', description: 'Old host child', startedAt: 600_000 },
+ {
+ id: 'command-1',
+ kind: 'command',
+ description: 'npm run dev',
+ startedAt: 300_000,
+ stoppable: false
+ },
+ { id: 'monitor-1', kind: 'monitor', description: 'tail -f server.log' }
+]
+
+const SETTLED_TASKS: AgentSessionBackgroundTask[] = [
+ {
+ id: 'agent-done',
+ kind: 'agent',
+ description: 'Summarize logs',
+ state: 'done',
+ startedAt: 100_000,
+ totalTokens: 900
+ },
+ { id: 'agent-stopped', kind: 'agent', description: 'Cancelled child', state: 'idle' },
+ { id: 'agent-failed', kind: 'agent', description: 'Failed child', state: 'blocked' }
+]
+
+function stripMarkup(supportsTaskStop: boolean): string {
+ vi.useFakeTimers()
+ vi.setSystemTime(NOW)
+ try {
+ return normalize(
+ renderToStaticMarkup(
+
+ {}}
+ onStop={() => {}}
+ />
+
+ )
+ )
+ } finally {
+ vi.useRealTimers()
+ }
+}
+
+describe('chat strip rows from a legacy background-task roster', () => {
+ it('render exactly as before', () => {
+ const markup = stripMarkup(true)
+ const container = document.createElement('div')
+ container.innerHTML = markup
+ expect([...container.querySelectorAll('li')].map((row) => row.textContent)).toEqual([
+ 'Failed child · failed',
+ 'Background agent · no contactStop',
+ 'Cancelled child',
+ 'Approve edits · needs approvalStop',
+ 'Summarize logs900',
+ 'Review the parser18.2k · 10m 0sStop',
+ 'Explore · failed8m 20sStop',
+ 'Old host child6m 40sStop',
+ 'npm run dev11m 40s',
+ 'tail -f server.logStop'
+ ])
+ expect(markup).toMatchSnapshot()
+ expect(stripMarkup(false)).toMatchSnapshot()
+ })
+})
From 2217e2fad9592ffacb136fbaec2d0da6f3e1f67b Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 23 Sep 2026 13:59:54 -0700
Subject: [PATCH 02/11] refactor(sidebar): one subagent row for CLI and
structured children, shared with the chat strip
A child row's dot, name and detail are now decided once, by a shared row model
(src/shared/agent-child-row-model.ts), and rendered by one piece
(AgentChildRowContent) that both the sidebar's child rows and the chat strip use.
The model reads the host's child views when a session publishes them and today's
legacy shapes otherwise (the subagents snapshot for the sidebar, the task roster for
the strip), so old hosts and CLI panes render exactly as before. From views it keeps
what the legacy path lost: a finished subagent whose shell still runs reads
monitoring through the shared child fold, a settled child reads by its outcome, the
tool it runs shows as the CLI row shows it, and each row keeps its own clock.
In the strip, work a child owns renders nested beneath it.
---
.../src/components/AgentChildRowContent.tsx | 58 ++++
.../src/components/agent-child-row-text.ts | 67 +++++
.../dashboard/DashboardAgentRow.tsx | 5 +-
.../dashboard/agent-finished-timestamp.ts | 8 +-
.../components/dashboard/useDashboardData.ts | 3 +
.../NativeChatBackgroundTasksStatus.tsx | 155 ++++++-----
.../background-task-header-content.ts | 4 +-
.../background-task-roster.test.ts | 20 +-
.../native-chat/background-task-roster.ts | 119 ++++++--
.../sidebar/worktree-card-agent-summary.ts | 7 +-
.../worktree-card-compact-agent-row.tsx | 77 +++---
.../sidebar/worktree-subagent-child-rows.ts | 146 ++++++----
src/renderer/src/lib/agent-row-dot-state.ts | 21 +-
src/shared/agent-child-row-model.ts | 260 ++++++++++++++++++
src/shared/agent-status-types.ts | 4 +
15 files changed, 763 insertions(+), 191 deletions(-)
create mode 100644 src/renderer/src/components/AgentChildRowContent.tsx
create mode 100644 src/renderer/src/components/agent-child-row-text.ts
create mode 100644 src/shared/agent-child-row-model.ts
diff --git a/src/renderer/src/components/AgentChildRowContent.tsx b/src/renderer/src/components/AgentChildRowContent.tsx
new file mode 100644
index 000000000000..717b8438a0b8
--- /dev/null
+++ b/src/renderer/src/components/AgentChildRowContent.tsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import type { AgentChildRowModel } from '../../../shared/agent-child-row-model'
+import { AgentStateDot } from '@/components/AgentStateDot'
+import type { StateIndicatorTooltipSide } from '@/components/StateIndicatorTooltip'
+import { agentChildRowText } from './agent-child-row-text'
+
+type AgentChildRowContentProps = {
+ row: AgentChildRowModel
+ now: number
+ /** Surface tone for the name and the detail; the words themselves are the row's. */
+ leadClassName: string
+ trailClassName: string
+ separator: string
+ /** Overrides the dot's hover label; null suppresses it for an existing tooltip. */
+ dotTitle?: string | null
+ tooltipSide?: StateIndicatorTooltipSide
+ /** Name the whole line on hover, for surfaces that truncate it tightly. */
+ lineTitle?: boolean
+}
+
+/**
+ * A child row's state dot, name and detail: the one piece the sidebar's child rows and the chat
+ * strip's rows both render, so the same child reads the same on both.
+ */
+export const AgentChildRowContent = React.memo(function AgentChildRowContent({
+ row,
+ now,
+ leadClassName,
+ trailClassName,
+ separator,
+ dotTitle,
+ tooltipSide,
+ lineTitle = false
+}: AgentChildRowContentProps): React.JSX.Element {
+ const { lead, trail } = agentChildRowText(row, now)
+ return (
+ <>
+
+
+ {lead}
+ {trail ? (
+
+ {separator}
+ {trail}
+
+ ) : null}
+
+ >
+ )
+})
diff --git a/src/renderer/src/components/agent-child-row-text.ts b/src/renderer/src/components/agent-child-row-text.ts
new file mode 100644
index 000000000000..6187d8ba9554
--- /dev/null
+++ b/src/renderer/src/components/agent-child-row-text.ts
@@ -0,0 +1,67 @@
+import type { AgentChildRowModel } from '../../../shared/agent-child-row-model'
+import { formatAgentTypeLabel } from '../../../shared/agent-type-label'
+import { formatNativeChatDuration } from '../../../shared/native-chat-turn-status'
+import { agentStateLabel } from '@/components/AgentStateDot'
+import { backgroundTaskStateReason } from '@/components/native-chat/background-task-roster'
+import { translate } from '@/i18n/i18n'
+import { agentNoUpdateLabel } from '@/lib/agent-row-decay-state'
+import { formatAgentToolPreview } from '@/lib/agent-row-tool-preview'
+
+export type AgentChildRowText = {
+ /** Leads the line; kept when the row truncates. */
+ lead: string
+ /** Follows the separator; '' when there is nothing more to say. */
+ trail: string
+}
+
+/** The words for a row's detail, reusing the phrasing a CLI agent row uses for the same fact. */
+export function agentChildRowDetailText(row: AgentChildRowModel, now: number): string {
+ const detail = row.detail
+ if (!detail) {
+ return ''
+ }
+ switch (detail.kind) {
+ case 'operation':
+ return formatAgentToolPreview(
+ { toolName: detail.toolName, toolInput: detail.input },
+ 'working'
+ )
+ case 'monitoring':
+ return agentStateLabel('monitoring')
+ case 'message':
+ return detail.text
+ case 'ended':
+ return translate('components.agentChildRow.ended', 'Ended')
+ case 'no-update':
+ return agentNoUpdateLabel({ updatedAt: row.recencyAt }, now)
+ case 'role':
+ return formatAgentTypeLabel(detail.agentType)
+ case 'reason':
+ return backgroundTaskStateReason(detail.state) ?? ''
+ }
+}
+
+/** The row's name, or its state when the child reported none. */
+export function agentChildRowName(row: AgentChildRowModel): string {
+ return row.name.trim() || agentStateLabel(row.displayState)
+}
+
+export function agentChildRowText(row: AgentChildRowModel, now: number): AgentChildRowText {
+ const name = agentChildRowName(row)
+ const detail = agentChildRowDetailText(row, now)
+ // Why: a monitoring row leads with its state so truncation keeps passive distinct from active.
+ if (row.displayState === 'monitoring' && detail) {
+ return { lead: detail, trail: detail === name ? '' : name }
+ }
+ return { lead: name, trail: detail }
+}
+
+/** "ended 3m ago" for a settled row whose host reported when; null otherwise. */
+export function agentChildRowEndedLabel(row: AgentChildRowModel, now: number): string | null {
+ if (!row.settled || row.settledAt === undefined) {
+ return null
+ }
+ return translate('components.agentChildRow.endedAgo', 'ended {{value0}} ago', {
+ value0: formatNativeChatDuration((now - row.settledAt) / 1000)
+ })
+}
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
index 1b64ec6af836..fe6ab7894b95 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
@@ -9,7 +9,7 @@ import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingCo
import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview'
import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state'
-import { agentRowDotState as asDotState } from '@/lib/agent-row-dot-state'
+import { agentRowDisplayDotState, agentRowDotState as asDotState } from '@/lib/agent-row-dot-state'
import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
@@ -152,8 +152,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
: [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ')
// Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a
// failure does so even while subagents still run.
- const dotState: AgentDotState =
- verdictDotState ?? asDotState(agent.state, agent.entry.workingMode)
+ const dotState: AgentDotState = agentRowDisplayDotState(agent)
const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now)
// Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the
// row's own timestamp slot rather than hiding in a hover tooltip.
diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts
index 28d7a2572bbb..d176ae0a879b 100644
--- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts
+++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts
@@ -12,11 +12,11 @@ import type { DashboardAgentRow } from './useDashboardData'
* agent reads "N since it finished", an active one falls through to its start.
*/
export function lastEnteredDoneAt(
- agent: Pick
+ agent: Pick
): number | null {
- // Why: a subagent's synthetic entry may say done while its row is idle or unverifiable.
- if (agent.rowSource === 'subagent' && agent.state !== 'done') {
- return null
+ // Why: a subagent has no turns; it ended when its host settled it, if it has.
+ if (agent.rowSource === 'subagent') {
+ return agent.childRow?.settledAt ?? null
}
const entry = agent.entry
// Why: same primitive Smart Sort ranks on, so the displayed age and Done eligibility share a clock.
diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts
index 02d4010edcdf..7ca391ef2346 100644
--- a/src/renderer/src/components/dashboard/useDashboardData.ts
+++ b/src/renderer/src/components/dashboard/useDashboardData.ts
@@ -1,6 +1,7 @@
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { AgentRowState } from '@/lib/agent-row-decay-state'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import type { AgentChildRowModel } from '../../../../shared/agent-child-row-model'
export type DashboardAgentRow = {
/** Row identity. For 'subagent' rows this is a synthetic key (the child has
@@ -18,6 +19,8 @@ export type DashboardAgentRow = {
* stateHistory entry, falling back to updatedAt when no history exists yet.
* Used to sort agents by when they started. */
startedAt: number
+ /** Subagent rows only: what the row shows, decided once for every surface. */
+ childRow?: AgentChildRowModel
lineage?: {
depth: 0 | 1
parentPaneKey?: string
diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
index 2e8cf51f18ff..4255b125043c 100644
--- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
@@ -1,7 +1,10 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Activity, Bot, ChevronDown, CircleHelp, SquareTerminal, Workflow } from 'lucide-react'
import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
-import { AgentStateDot } from '@/components/AgentStateDot'
+import type { AgentChildRowModel } from '../../../../shared/agent-child-row-model'
+import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
+import { AgentChildRowContent } from '@/components/AgentChildRowContent'
+import { agentChildRowEndedLabel, agentChildRowName } from '@/components/agent-child-row-text'
import { Button } from '@/components/ui/button'
import { useNow } from '@/hooks/use-now'
import { translate } from '@/i18n/i18n'
@@ -9,10 +12,10 @@ import { backgroundTasksHeaderContent } from './background-task-header-content'
import {
backgroundTaskElapsedLabel,
backgroundTaskGroupLabel,
- backgroundTaskStateReason,
buildBackgroundTaskGroups,
+ buildBackgroundTaskGroupsFromViews,
formatBackgroundTaskTokens,
- type BackgroundRosterTask
+ type BackgroundTaskGroup
} from './background-task-roster'
/** Below this strip width (border-box, live root font size) the header drops
@@ -64,78 +67,99 @@ function kindIconTone(kind: AgentSessionBackgroundTask['kind'], dimmed: boolean)
return dimmed ? `${tone}/40` : tone
}
-/** Absent means stoppable: a host predating the field published only rows its
- * stop could act on, so reading absence as "not stoppable" would hide a
- * working control. Only an explicit `false` withholds the button — Claude
- * marks its in-turn foreground rows that way, and a Stop on one of those
- * resolves to an empty target list and silently reports nothing cancelled. */
-function backgroundTaskStoppable(task: AgentSessionBackgroundTask): boolean {
- return task.stoppable !== false
+/** A still-growing clock on finished work would lie: settled rows say when they ended instead. */
+function rowHasClock(row: AgentChildRowModel): boolean {
+ return row.settled ? row.settledAt !== undefined : row.firstObservedAt > 0
}
function BackgroundTaskRow(props: {
- entry: BackgroundRosterTask
+ row: AgentChildRowModel
now: number
supportsTaskStop: boolean
- stopping: boolean
+ stoppingTaskIds: ReadonlySet
onStop: (taskId: string) => void
}): React.JSX.Element {
- const { entry, now } = props
- const Icon = KIND_ICONS[entry.task.kind]
- // Every attention state states its reason on the row, the same ones the collapsed
- // header names; `unverifiable` ("no contact") must never be silently dropped.
- const reason = backgroundTaskStateReason(entry.state)
- // Settled rows keep their final usage but no elapsed — a still-growing clock
- // on finished work would lie.
+ const { row, now } = props
+ const Icon = KIND_ICONS[row.kind]
const meta = [
- entry.task.totalTokens !== undefined
- ? formatBackgroundTaskTokens(entry.task.totalTokens)
- : null,
- entry.settled ? null : backgroundTaskElapsedLabel(entry.task, now)
+ row.totalTokens !== undefined ? formatBackgroundTaskTokens(row.totalTokens) : null,
+ row.settled
+ ? agentChildRowEndedLabel(row, now)
+ : backgroundTaskElapsedLabel(row.firstObservedAt, now)
]
.filter((part): part is string => part !== null)
.join(' · ')
+ // Only an explicit `false` withholds the button: a Stop on a row the host cannot target
+ // resolves to an empty list and silently reports nothing cancelled.
+ const stopId =
+ !row.settled && props.supportsTaskStop && row.canStop ? (row.providerId ?? null) : null
return (
-
-
-
-
- {entry.name}
- {reason ? · {reason} : null}
-
- {meta ? (
-
- {meta}
-
- ) : null}
- {!entry.settled && props.supportsTaskStop && backgroundTaskStoppable(entry.task) ? (
- props.onStop(entry.task.id)}
- >
- {translate('components.native-chat.backgroundTasks.stop', 'Stop')}
-
+ <>
+
+
+ {/* Every attention state states its reason on the row; `unverifiable` is never dropped. */}
+
+ {meta ? (
+
+ {meta}
+
+ ) : null}
+ {stopId !== null ? (
+ props.onStop(stopId)}
+ >
+ {translate('components.native-chat.backgroundTasks.stop', 'Stop')}
+
+ ) : null}
+
+ {row.owned.length > 0 ? (
+ // Work a child owns (its shell, a nested agent) reads beneath it, not in its kind's group.
+
+
+ {row.owned.map((owned) => (
+
+ ))}
+
+
) : null}
-
+ >
)
}
+function groupsHaveClock(rows: readonly AgentChildRowModel[]): boolean {
+ return rows.some((row) => rowHasClock(row) || groupsHaveClock(row.owned))
+}
+
export function NativeChatBackgroundTasksStatus(props: {
tasks: readonly AgentSessionBackgroundTask[]
settledTasks: readonly AgentSessionBackgroundTask[]
+ /** The host's child views, when it publishes them; rows then read these instead of the tasks. */
+ children?: readonly AgentChildWorkView[]
supportsTaskStop: boolean
/** False when the provider exposes no honest stop at all; the fallback
* control is hidden rather than offering a button that cannot act. */
@@ -160,15 +184,16 @@ export function NativeChatBackgroundTasksStatus(props: {
const stripRef = useRef(null)
const narrow = useNarrowStrip(stripRef)
// The 1 Hz elapsed tick must not re-group, re-sort and re-translate the whole roster.
- const groups = useMemo(
- () => buildBackgroundTaskGroups(props.tasks, props.settledTasks),
- [props.tasks, props.settledTasks]
+ const groups: BackgroundTaskGroup[] = useMemo(
+ () =>
+ props.children !== undefined
+ ? buildBackgroundTaskGroupsFromViews(props.children)
+ : buildBackgroundTaskGroups(props.tasks, props.settledTasks),
+ [props.children, props.tasks, props.settledTasks]
)
const singleLiveCommand =
groups.length === 1 && groups[0].kind === 'command' && groups[0].tasks.length === 1
- const hasElapsed = groups.some((group) =>
- group.tasks.some((entry) => !entry.settled && (entry.task.startedAt ?? 0) > 0)
- )
+ const hasElapsed = groups.some((group) => groupsHaveClock(group.tasks.map((entry) => entry.row)))
const now = useNow(1_000, props.isVisible && hasElapsed && (expanded || singleLiveCommand))
const header = backgroundTasksHeaderContent(groups, { narrow, now })
const headerText = `${header.segments.map((segment) => segment.text).join(' · ')}${header.detail ? `${header.segments.length > 0 ? ' — ' : ''}${header.detail}` : ''}`
@@ -250,11 +275,11 @@ export function NativeChatBackgroundTasksStatus(props: {
>
{group.tasks.map((entry) => (
))}
diff --git a/src/renderer/src/components/native-chat/background-task-header-content.ts b/src/renderer/src/components/native-chat/background-task-header-content.ts
index 0ec1c765e1f8..acbd97cb5431 100644
--- a/src/renderer/src/components/native-chat/background-task-header-content.ts
+++ b/src/renderer/src/components/native-chat/background-task-header-content.ts
@@ -166,8 +166,8 @@ export function backgroundTasksHeaderContent(
: kindCountLabel(group.kind, 1)
// A still-growing clock on finished work would lie, exactly as on the row.
const elapsed =
- group.kind === 'command' && !entry.settled
- ? backgroundTaskElapsedLabel(entry.task, options.now)
+ group.kind === 'command' && !entry.row.settled
+ ? backgroundTaskElapsedLabel(entry.row.firstObservedAt, options.now)
: null
return {
segments: [{ text: subject, kind: group.kind }],
diff --git a/src/renderer/src/components/native-chat/background-task-roster.test.ts b/src/renderer/src/components/native-chat/background-task-roster.test.ts
index eb2232056a7b..f4159632c692 100644
--- a/src/renderer/src/components/native-chat/background-task-roster.test.ts
+++ b/src/renderer/src/components/native-chat/background-task-roster.test.ts
@@ -172,8 +172,8 @@ describe('buildBackgroundTaskGroups', () => {
[agent('settled', { state: 'done', startedAt: 0 })]
)
expect(built.map((group) => group.kind)).toEqual(['agent', 'monitor'])
- expect(built[0].tasks.map((entry) => entry.task.id)).toEqual(['settled', 'early', 'late'])
- expect(built[0].tasks[0].settled).toBe(true)
+ expect(built[0].tasks.map((entry) => entry.row.id)).toEqual(['settled', 'early', 'late'])
+ expect(built[0].tasks[0].row.settled).toBe(true)
})
it('defaults the state slot so a stateless row still reads as work', () => {
@@ -234,9 +234,19 @@ describe('resumed tasks from mixed-version hosts', () => {
[live, ...shells],
[settled, agent('sibling', { state: 'done' })]
)
- expect(
- groups.flatMap((group) => group.tasks).filter((entry) => entry.task.id === live.id)
- ).toEqual([{ task: live, settled: false, state: 'working', name: 'Background agent' }])
+ const owners = groups
+ .flatMap((group) => group.tasks)
+ .filter((entry) => entry.row.id === live.id)
+ expect(owners).toHaveLength(1)
+ expect(owners[0]).toMatchObject({
+ row: {
+ settled: false,
+ displayState: 'working',
+ name: 'Background agent',
+ totalTokens: 20000
+ },
+ state: 'working'
+ })
expect(backgroundTasksHeaderContent(groups, { narrow: false, now: NOW }).segments).toEqual([
{ text: '2 agents', kind: 'agent' },
{ text: '4 shells', kind: 'command' }
diff --git a/src/renderer/src/components/native-chat/background-task-roster.ts b/src/renderer/src/components/native-chat/background-task-roster.ts
index 4b1c290e9444..32cda7cad1a1 100644
--- a/src/renderer/src/components/native-chat/background-task-roster.ts
+++ b/src/renderer/src/components/native-chat/background-task-roster.ts
@@ -6,6 +6,15 @@ import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskRunState
} from '../../../../shared/agent-session-wire'
+import {
+ buildAgentChildRowModels,
+ type AgentChildRowContext,
+ type AgentChildRowModel
+} from '../../../../shared/agent-child-row-model'
+import type {
+ AgentChildDisplayState,
+ AgentChildWorkView
+} from '../../../../shared/agent-status-child-work-view'
import { formatNativeChatDuration } from '../../../../shared/native-chat-turn-status'
import { translate } from '@/i18n/i18n'
@@ -13,10 +22,10 @@ type TaskKind = AgentSessionBackgroundTask['kind']
type RunState = AgentSessionBackgroundTaskRunState
export type BackgroundRosterTask = {
- task: AgentSessionBackgroundTask
- settled: boolean
+ /** What the row shows, decided by the same model the sidebar's child rows use. */
+ row: AgentChildRowModel
+ /** The row's display state in the header's vocabulary. */
state: RunState
- name: string
}
export type BackgroundTaskGroup = { kind: TaskKind; tasks: BackgroundRosterTask[] }
@@ -70,8 +79,43 @@ function effectiveState(task: AgentSessionBackgroundTask, settled: boolean): Run
return task.kind === 'monitor' ? 'monitoring' : 'working'
}
-/** Merge live and settled tasks into kind groups, stable-sorted first-seen
- * (startedAt) then id, so a live update never reshuffles surviving rows. */
+/** A host that publishes only the task roster decided each row's state; the row keeps it. */
+function legacyTaskRow(task: AgentSessionBackgroundTask, settled: boolean): AgentChildRowModel {
+ const state = effectiveState(task, settled)
+ const startedAt = task.startedAt ?? 0
+ return {
+ id: task.id,
+ providerId: task.id,
+ kind: task.kind,
+ displayState: state,
+ name: resolveBackgroundTaskName(task),
+ detail:
+ state === 'waiting' || state === 'blocked' || state === 'unverifiable'
+ ? { kind: 'reason', state }
+ : null,
+ firstObservedAt: startedAt,
+ recencyAt: startedAt,
+ ...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}),
+ // Absent means stoppable: a host predating the field published only rows its stop could act on.
+ canStop: task.stoppable !== false,
+ settled,
+ owned: []
+ }
+}
+
+/** Stable-sort first-seen then id, so a live update never reshuffles surviving rows. */
+function groupRosterEntries(entries: BackgroundRosterTask[]): BackgroundTaskGroup[] {
+ entries.sort((left, right) => {
+ const startDelta = left.row.firstObservedAt - right.row.firstObservedAt
+ return startDelta !== 0 ? startDelta : left.row.id < right.row.id ? -1 : 1
+ })
+ return KIND_ORDER.map((kind) => ({
+ kind,
+ tasks: entries.filter((entry) => entry.row.kind === kind)
+ })).filter((group) => group.tasks.length > 0)
+}
+
+/** Merge live and settled tasks into kind groups. */
export function buildBackgroundTaskGroups(
tasks: readonly AgentSessionBackgroundTask[],
settledTasks: readonly AgentSessionBackgroundTask[]
@@ -83,23 +127,49 @@ export function buildBackgroundTaskGroups(
[tasks, false]
] as const) {
for (const task of roster) {
- owners.set(task.id, {
- task,
- settled,
- state: effectiveState(task, settled),
- name: resolveBackgroundTaskName(task)
- })
+ const row = legacyTaskRow(task, settled)
+ owners.set(task.id, { row, state: headerRunState(row.displayState) })
}
}
- const entries = [...owners.values()]
- entries.sort((left, right) => {
- const startDelta = (left.task.startedAt ?? 0) - (right.task.startedAt ?? 0)
- return startDelta !== 0 ? startDelta : left.task.id < right.task.id ? -1 : 1
- })
- return KIND_ORDER.map((kind) => ({
- kind,
- tasks: entries.filter((entry) => entry.task.kind === kind)
- })).filter((group) => group.tasks.length > 0)
+ return groupRosterEntries([...owners.values()])
+}
+
+// The strip reads a live session channel; contact loss is not yet a verdict it receives.
+const STRIP_ROW_CONTEXT: AgentChildRowContext = {
+ parentEvidenceFresh: true,
+ transportObservation: 'live',
+ parentObservedAt: 0
+}
+
+/** The header's word for a row: a failure reads as the host's `blocked`, a cancel as `idle`. */
+function headerRunState(displayState: AgentChildDisplayState): RunState {
+ switch (displayState) {
+ case 'failed':
+ return 'blocked'
+ case 'interrupted':
+ return 'idle'
+ case 'working':
+ case 'monitoring':
+ case 'waiting':
+ case 'blocked':
+ case 'done':
+ case 'idle':
+ case 'unverifiable':
+ return displayState
+ }
+}
+
+/** Kind groups from the host's child views: the main agent's work at the top, each child's own
+ * work nested beneath it rather than counted again in its kind's group. */
+export function buildBackgroundTaskGroupsFromViews(
+ views: readonly AgentChildWorkView[]
+): BackgroundTaskGroup[] {
+ return groupRosterEntries(
+ buildAgentChildRowModels(views, STRIP_ROW_CONTEXT).map((row) => ({
+ row,
+ state: headerRunState(row.displayState)
+ }))
+ )
}
export function backgroundTaskStateWord(state: RunState): string {
@@ -155,14 +225,11 @@ export function formatBackgroundTaskTokens(totalTokens: number): string {
: `${tokenScaleText(Math.round(totalTokens / 100_000) / 10)}m`
}
-export function backgroundTaskElapsedLabel(
- task: AgentSessionBackgroundTask,
- now: number
-): string | null {
- if (task.startedAt === undefined || task.startedAt <= 0) {
+export function backgroundTaskElapsedLabel(startedAt: number, now: number): string | null {
+ if (startedAt <= 0) {
return null
}
- return formatNativeChatDuration((now - task.startedAt) / 1000)
+ return formatNativeChatDuration((now - startedAt) / 1000)
}
export function backgroundTaskGroupLabel(kind: TaskKind): string {
diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts
index 4ce3f1ad4176..76fe81307e90 100644
--- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts
@@ -1,8 +1,7 @@
import type { AgentDotState } from '@/components/AgentStateDot'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { formatAgentTypeLabel } from '@/lib/agent-status'
-import { agentRowDotState } from '@/lib/agent-row-dot-state'
-import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict'
+import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state'
export type SummaryAgentGroup = {
state: AgentDotState
@@ -24,9 +23,7 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [
]
export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState {
- return (
- agentVerdictDisplayMark(agent.entry) ?? agentRowDotState(agent.state, agent.entry.workingMode)
- )
+ return agentRowDisplayDotState(agent)
}
export function formatSummaryStateLabel(state: AgentDotState): string {
diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
index 094cd2e7db9d..7ee488341852 100644
--- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
+++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { DashboardAgentChildDisclosure } from '@/components/dashboard/DashboardAgentChildDisclosure'
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
+import { AgentChildRowContent } from '@/components/AgentChildRowContent'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
@@ -183,41 +184,55 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
) : null
+ // Why: the selected-row fill is strong enough to wash out the dimmed prompt/secondary text, so
+ // lift the lead toward full foreground when focused; an unvisited row stays bold either way.
+ const leadClassName = cn(
+ isUnvisited ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground/90',
+ isFocusedPane && !isUnvisited && 'text-foreground'
+ )
+
const rowBody = (
<>
- {/* Why: the row's actionable disabled reason must win on every hit area. */}
-
- {!hideIcon && (
-
-
-
- )}
-
- {/* Why: the selected-row fill is strong enough to wash out the dimmed
- prompt/secondary text, so lift both toward full foreground when focused. */}
-
+ ) : (
+ <>
+ {/* Why: the row's actionable disabled reason must win on every hit area. */}
+
+ {!hideIcon && (
+
+
+
)}
- >
- {leadingText}
-
- {trailingText && (
-
- {' '}
- - {trailingText}
+
+ {leadingText}
+ {trailingText && (
+
+ {' '}
+ - {trailingText}
+
+ )}
- )}
-
+ >
+ )}
{model && (
0 ? row.firstObservedAt : parentEntry.stateStartedAt
+ const paneKey = subagentRowKey(parentEntry.paneKey, row.id)
+ const detail = row.detail
+ // The same fields a CLI agent row carries, so every reader of a CLI row reads a child the same way.
+ const entry: AgentStatusEntry = {
+ state: state === 'idle' || state === 'unverifiable' ? 'done' : state,
+ ...(row.displayState === 'monitoring' ? { workingMode: 'monitoring' as const } : {}),
+ prompt: row.name,
+ // The parent's delivery clock; the child's own evidence clock rides `evidenceObservedAt`.
+ updatedAt: parentEntry.updatedAt,
+ ...(row.observedAt !== undefined ? { evidenceObservedAt: row.observedAt } : {}),
+ stateStartedAt: startedAt,
+ agentType: row.agentType,
+ model: row.model,
+ ...(detail?.kind === 'operation'
+ ? {
+ toolName: detail.toolName,
+ ...(detail.input !== undefined ? { toolInput: detail.input } : {})
+ }
+ : {}),
+ ...(detail?.kind === 'message' ? { lastAssistantMessage: detail.text } : {}),
+ paneKey,
+ worktreeId: parentEntry.worktreeId,
+ tabId: parentEntry.tabId,
+ stateHistory: [],
+ orchestration: {
+ taskId: `subagent:${row.id}`,
+ dispatchId: `subagent:${row.id}`,
+ displayName: row.name || undefined,
+ parentPaneKey: parentEntry.paneKey
+ }
+ }
+ return {
+ paneKey,
+ entry,
+ tab,
+ agentType: row.agentType ?? 'unknown',
+ rowSource: 'subagent',
+ state,
+ activationPaneKey: parentEntry.paneKey,
+ startedAt,
+ childRow: row
+ }
+}
+
/**
- * Derive indented child rows for the live in-process subagents/teammates a
- * pane's agent has spawned (entry.subagents, reported via agent hooks). These
- * children have no PTY or tab of their own: the rows reuse the parent's tab,
- * activate the parent's pane, and link into the existing lineage tree through
- * `orchestration.parentPaneKey`.
+ * Derive indented child rows for the subagents/teammates a pane's agent has
+ * spawned. These children have no PTY or tab of their own: the rows reuse the
+ * parent's tab, activate the parent's pane, and link into the existing lineage
+ * tree through `orchestration.parentPaneKey`. The host's child views win; a host
+ * that sends only the legacy `subagents` snapshot (an old host, any CLI pane)
+ * is read as before.
*/
export function buildSubagentChildRows(args: {
parentEntry: AgentStatusEntry
@@ -25,48 +103,18 @@ export function buildSubagentChildRows(args: {
* states are equally unverifiable. */
parentIsFresh: boolean
}): DashboardAgentRow[] {
- const subagents = args.parentEntry.subagents
- if (!subagents || subagents.length === 0) {
- return []
+ const { parentEntry } = args
+ const context = {
+ parentEvidenceFresh: args.parentIsFresh,
+ transportObservation: parentEntry.subagentObservation ?? 'live',
+ parentObservedAt: parentEntry.updatedAt
}
- return subagents.map((subagent) => {
- const freshness = resolveAgentChildWorkFreshness({
- state: subagent.state,
- membership: 'live',
- parentEvidenceFresh: args.parentIsFresh,
- transportObservation: args.parentEntry.subagentObservation ?? 'live'
- })
- const state = freshness === 'done' ? 'idle' : freshness === 'monitoring' ? 'working' : freshness
- const activeState = state !== 'idle' && state !== 'unverifiable' ? state : undefined
- const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt
- const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id)
- const entry: AgentStatusEntry = {
- state: activeState ?? 'done',
- prompt: subagent.description ?? subagent.agentType ?? '',
- updatedAt: args.parentEntry.updatedAt,
- stateStartedAt: startedAt,
- agentType: subagent.agentType,
- model: subagent.model,
- paneKey,
- worktreeId: args.parentEntry.worktreeId,
- tabId: args.parentEntry.tabId,
- stateHistory: [],
- orchestration: {
- taskId: `subagent:${subagent.id}`,
- dispatchId: `subagent:${subagent.id}`,
- displayName: subagent.description,
- parentPaneKey: args.parentEntry.paneKey
- }
- }
- return {
- paneKey,
- entry,
- tab: args.tab,
- agentType: subagent.agentType ?? 'unknown',
- rowSource: 'subagent' as const,
- state,
- activationPaneKey: args.parentEntry.paneKey,
- startedAt
- }
- })
+ const rows =
+ parentEntry.children !== undefined
+ ? buildAgentChildRowModels(parentEntry.children, context)
+ : buildLegacyAgentChildRowModels(parentEntry.subagents ?? [], context)
+ // Shells and monitors show through their owner's dot; the sidebar lists agents.
+ return flattenAgentChildRowModels(rows)
+ .filter((row) => row.kind === 'agent')
+ .map((row) => childDashboardRow(row, parentEntry, args.tab))
}
diff --git a/src/renderer/src/lib/agent-row-dot-state.ts b/src/renderer/src/lib/agent-row-dot-state.ts
index 79c0d7c198c5..bf2b7b1e5320 100644
--- a/src/renderer/src/lib/agent-row-dot-state.ts
+++ b/src/renderer/src/lib/agent-row-dot-state.ts
@@ -1,5 +1,7 @@
import type { AgentDotState } from '@/components/AgentStateDot'
-import type { AgentWorkingMode } from '../../../shared/agent-status-types'
+import type { AgentChildRowModel } from '../../../shared/agent-child-row-model'
+import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict'
+import type { AgentStatusEntry, AgentWorkingMode } from '../../../shared/agent-status-types'
import type { AgentRowState } from './agent-row-decay-state'
/**
@@ -22,3 +24,20 @@ export function agentRowDotState(
}
return 'idle'
}
+
+/**
+ * The dot an agent row renders: a child row's own, else the main agent's verdict (a stop or a
+ * failure, which a failure shows even while subagents still run), else its state.
+ */
+export function agentRowDisplayDotState(agent: {
+ state: AgentRowState
+ entry: Pick
+ childRow?: Pick
+}): AgentDotState {
+ if (agent.childRow) {
+ return agent.childRow.displayState
+ }
+ return (
+ agentVerdictDisplayMark(agent.entry) ?? agentRowDotState(agent.state, agent.entry.workingMode)
+ )
+}
diff --git a/src/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts
new file mode 100644
index 000000000000..0c8f75b764e7
--- /dev/null
+++ b/src/shared/agent-child-row-model.ts
@@ -0,0 +1,260 @@
+import type { AgentChildWorkKind } from './agent-status-child-work'
+import { resolveAgentChildWorkFreshness } from './agent-status-child-work-freshness'
+import {
+ agentChildWorkOwnedLiveness,
+ deriveAgentChildDisplayState,
+ type AgentChildDisplayState,
+ type AgentChildWorkView
+} from './agent-status-child-work-view'
+import type { AgentSubagentSnapshot } from './agent-status-types'
+
+/** What a child row says beside its name. Surfaces format it; they never decide it. */
+export type AgentChildRowDetail =
+ /** The tool the child is running now, as `tool: input` — the line a CLI agent row shows. */
+ | { kind: 'operation'; toolName: string; input?: string }
+ /** Its own work is over and only work it owns still runs; the tool line is stale. */
+ | { kind: 'monitoring' }
+ | { kind: 'message'; text: string }
+ /** Settled, and the lane cannot say how. */
+ | { kind: 'ended' }
+ /** How long since the last evidence for this child (see `recencyAt`). */
+ | { kind: 'no-update' }
+ /** The child's role, when nothing more specific is known. */
+ | { kind: 'role'; agentType: string }
+ /** A host that publishes only a run state: that state's reason word. */
+ | { kind: 'reason'; state: 'waiting' | 'blocked' | 'unverifiable' }
+
+/** One child row, for every surface that lists child work (sidebar and chat strip). */
+export type AgentChildRowModel = {
+ /** Row identity: the host's child id, or the provider id an old host names it by. */
+ id: string
+ /** The id a targeted stop names; absent when the host holds none. */
+ providerId?: string
+ kind: AgentChildWorkKind
+ displayState: AgentChildDisplayState
+ /** '' when the child reported no label; a surface then names the row by its state. */
+ name: string
+ agentType?: string
+ model?: string
+ detail: AgentChildRowDetail | null
+ /** When the child was first seen: the elapsed anchor. */
+ firstObservedAt: number
+ /** Last evidence for THIS child. Absent from a host that reports none. */
+ observedAt?: number
+ /** The clock a "no update" reading measures: the child's own, else its parent's. */
+ recencyAt: number
+ settledAt?: number
+ totalTokens?: number
+ canStop: boolean
+ settled: boolean
+ /** Work this child owns (a nested agent, a shell it launched), in host order. */
+ owned: AgentChildRowModel[]
+}
+
+export type AgentChildRowContext = {
+ /** A stale parent makes every live claim beneath it unverifiable. */
+ parentEvidenceFresh: boolean
+ transportObservation: 'live' | 'unverifiable'
+ /** The parent's evidence clock: recency for a child whose host reports no clock of its own. */
+ parentObservedAt: number
+}
+
+/** Provider strings that carry no identity; the next label wins. */
+const PLACEHOLDER_LABELS = new Set(['unknown', 'untitled', 'task', 'subagent'])
+
+function usableLabel(value: string | undefined): string | undefined {
+ const trimmed = value?.trim()
+ return trimmed && !PLACEHOLDER_LABELS.has(trimmed.toLowerCase()) ? trimmed : undefined
+}
+
+// A live claim beneath a parent Orca no longer hears from asserts nothing; settled history stands.
+function withFreshness(
+ displayState: AgentChildDisplayState,
+ context: AgentChildRowContext
+): AgentChildDisplayState {
+ if (
+ displayState === 'done' ||
+ displayState === 'failed' ||
+ displayState === 'interrupted' ||
+ displayState === 'idle'
+ ) {
+ return displayState
+ }
+ return resolveAgentChildWorkFreshness({
+ state: displayState,
+ membership: 'live',
+ parentEvidenceFresh: context.parentEvidenceFresh,
+ transportObservation: context.transportObservation
+ })
+}
+
+type AgentChildRowDetailSource = {
+ kind: AgentChildWorkKind
+ name: string
+ agentType?: string
+ operation?: { toolName: string; input?: string }
+ lastMessage?: string
+ settled: boolean
+}
+
+// A role that restates the name adds nothing.
+function roleDetail(source: AgentChildRowDetailSource): AgentChildRowDetail | null {
+ if (source.kind !== 'agent') {
+ return null
+ }
+ const agentType = source.agentType ?? 'unknown'
+ return source.name.trim() === agentType.trim() ? null : { kind: 'role', agentType }
+}
+
+function messageOrRole(source: AgentChildRowDetailSource): AgentChildRowDetail | null {
+ const text = source.lastMessage?.trim()
+ return text ? { kind: 'message', text } : roleDetail(source)
+}
+
+/** The one rule for what a child row says, in the order a CLI agent row decides it. */
+function agentChildRowDetail(
+ source: AgentChildRowDetailSource,
+ displayState: AgentChildDisplayState
+): AgentChildRowDetail | null {
+ switch (displayState) {
+ case 'unverifiable':
+ return { kind: 'no-update' }
+ case 'monitoring':
+ // A shell or monitor names itself; only an agent's own tool line goes stale.
+ return source.kind === 'agent' ? { kind: 'monitoring' } : null
+ case 'working':
+ case 'waiting':
+ return source.operation
+ ? {
+ kind: 'operation',
+ toolName: source.operation.toolName,
+ ...(source.operation.input !== undefined ? { input: source.operation.input } : {})
+ }
+ : messageOrRole(source)
+ case 'blocked':
+ case 'done':
+ case 'failed':
+ return messageOrRole(source)
+ case 'interrupted':
+ return roleDetail(source)
+ case 'idle':
+ return source.settled ? { kind: 'ended' } : roleDetail(source)
+ }
+}
+
+function viewName(view: AgentChildWorkView): string {
+ return (
+ usableLabel(view.description) ?? usableLabel(view.name) ?? usableLabel(view.agentType) ?? ''
+ )
+}
+
+function rowFromView(
+ view: AgentChildWorkView,
+ views: readonly AgentChildWorkView[],
+ ownedByOwner: ReadonlyMap,
+ context: AgentChildRowContext,
+ path: ReadonlySet
+): AgentChildRowModel {
+ const displayState = withFreshness(
+ deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)),
+ context
+ )
+ const settled = view.membership === 'settled'
+ const name = viewName(view)
+ const nextPath = new Set(path).add(view.id)
+ return {
+ id: view.id,
+ ...(view.providerId !== undefined ? { providerId: view.providerId } : {}),
+ kind: view.kind,
+ displayState,
+ name,
+ ...(view.agentType !== undefined ? { agentType: view.agentType } : {}),
+ ...(view.model !== undefined ? { model: view.model } : {}),
+ detail: agentChildRowDetail(
+ {
+ kind: view.kind,
+ name,
+ agentType: view.agentType,
+ operation: view.operation,
+ lastMessage: view.lastMessage,
+ settled
+ },
+ displayState
+ ),
+ firstObservedAt: view.firstObservedAt,
+ observedAt: view.observedAt,
+ recencyAt: view.observedAt,
+ ...(view.settledAt !== undefined ? { settledAt: view.settledAt } : {}),
+ ...(view.totalTokens !== undefined ? { totalTokens: view.totalTokens } : {}),
+ canStop: !settled && view.stoppable && view.providerId !== undefined,
+ settled,
+ owned: (ownedByOwner.get(view.id) ?? [])
+ .filter((owned) => !nextPath.has(owned.id))
+ .map((owned) => rowFromView(owned, views, ownedByOwner, context, nextPath))
+ }
+}
+
+/**
+ * Child rows from the host's views: the main agent's children at the top, and everything a child
+ * owns nested beneath it. Display state comes from the shared child fold, so a finished subagent
+ * whose shell still runs reads `monitoring` exactly as a CLI agent does.
+ */
+export function buildAgentChildRowModels(
+ views: readonly AgentChildWorkView[],
+ context: AgentChildRowContext
+): AgentChildRowModel[] {
+ const ownedByOwner = new Map()
+ for (const view of views) {
+ if (view.parentChildWorkId !== undefined) {
+ ownedByOwner.set(view.parentChildWorkId, [
+ ...(ownedByOwner.get(view.parentChildWorkId) ?? []),
+ view
+ ])
+ }
+ }
+ return views
+ .filter((view) => view.parentChildWorkId === undefined)
+ .map((view) => rowFromView(view, views, ownedByOwner, context, new Set()))
+}
+
+/**
+ * Child rows from a host that sends only the legacy `subagents` snapshot (an old host, or any CLI
+ * pane): live agents with a lifecycle state and a spawn time, and no clock of their own.
+ */
+export function buildLegacyAgentChildRowModels(
+ subagents: readonly AgentSubagentSnapshot[],
+ context: AgentChildRowContext
+): AgentChildRowModel[] {
+ return subagents.map((subagent) => {
+ const displayState = withFreshness(
+ deriveAgentChildDisplayState({ state: subagent.state, membership: 'live' }, null),
+ context
+ )
+ const name = subagent.description ?? subagent.agentType ?? ''
+ return {
+ id: subagent.id,
+ providerId: subagent.id,
+ kind: 'agent',
+ displayState,
+ name,
+ ...(subagent.agentType !== undefined ? { agentType: subagent.agentType } : {}),
+ ...(subagent.model !== undefined ? { model: subagent.model } : {}),
+ detail: agentChildRowDetail(
+ { kind: 'agent', name, agentType: subagent.agentType, settled: false },
+ displayState
+ ),
+ firstObservedAt: subagent.startedAt,
+ recencyAt: context.parentObservedAt,
+ canStop: false,
+ settled: false,
+ owned: []
+ }
+ })
+}
+
+/** Every row of a tree, owners before what they own. */
+export function flattenAgentChildRowModels(
+ rows: readonly AgentChildRowModel[]
+): AgentChildRowModel[] {
+ return rows.flatMap((row) => [row, ...flattenAgentChildRowModels(row.owned)])
+}
diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts
index 0150ef2b7fdc..0a0989425f18 100644
--- a/src/shared/agent-status-types.ts
+++ b/src/shared/agent-status-types.ts
@@ -8,6 +8,7 @@ import type { AgentStateHistoryEntry } from './agent-state-history'
import { isAgentJournalTurnOutcome } from './agent-turn-outcome'
import type { OrchestrationFleetAttention } from './orchestration-fleet-attention'
import type { AgentStatusRowFacets } from './agent-status-observation'
+import type { AgentChildWorkView } from './agent-status-child-work-view'
import type { TuiAgent } from './tui-agent'
import {
normalizeInteractivePromptField,
@@ -142,6 +143,9 @@ export type AgentStatusEntry = {
/** The main agent's own state; absent from old hosts and from writers that carry no main agent fact
* (OSC, launch seeds), where readers fall back to `state`. */
mainAgent?: AgentMainAgentStatus
+ /** The host's child-work views for this session, when it publishes them; child rows then read
+ * these instead of `subagents`. Absent from older hosts. */
+ children?: AgentChildWorkView[]
/** Provider-owned conversation/session id captured from hook payloads.
* Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */
providerSession?: AgentProviderSessionMetadata
From 61ecc2d96609261642f4d2872e1b95f161969d01 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 23 Sep 2026 14:02:44 -0700
Subject: [PATCH 03/11] i18n: add the child row's Ended and ended-ago strings
to every catalog
---
src/renderer/src/i18n/locales/en.json | 4 ++++
src/renderer/src/i18n/locales/es.json | 4 ++++
src/renderer/src/i18n/locales/fr.json | 4 ++++
src/renderer/src/i18n/locales/ja.json | 4 ++++
src/renderer/src/i18n/locales/ko.json | 4 ++++
src/renderer/src/i18n/locales/zh.json | 4 ++++
6 files changed, 24 insertions(+)
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index ffdcd6031c2f..0d54c5c5a2a2 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18196,6 +18196,10 @@
}
}
}
+ },
+ "agentChildRow": {
+ "ended": "Ended",
+ "endedAgo": "ended {{value0}} ago"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index adbe527574ae..9b42747499c9 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -15168,6 +15168,10 @@
"copySessionIdSuccess": "ID de sesión copiado",
"copySessionIdError": "No se pudo copiar el ID de sesión"
}
+ },
+ "agentChildRow": {
+ "ended": "Finalizado",
+ "endedAgo": "finalizó hace {{value0}}"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 047564c4e617..ac1b5f51c80a 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -18168,6 +18168,10 @@
}
}
}
+ },
+ "agentChildRow": {
+ "ended": "Terminé",
+ "endedAgo": "terminé il y a {{value0}}"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 97ff2a6ffdae..d3cade6b831b 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -18069,6 +18069,10 @@
}
}
}
+ },
+ "agentChildRow": {
+ "ended": "終了",
+ "endedAgo": "{{value0}}前に終了"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 511ee010e702..8e76f570bf6f 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -18069,6 +18069,10 @@
}
}
}
+ },
+ "agentChildRow": {
+ "ended": "종료됨",
+ "endedAgo": "{{value0}} 전에 종료됨"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index e4fa1521899a..0b2da20f4271 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -18069,6 +18069,10 @@
}
}
}
+ },
+ "agentChildRow": {
+ "ended": "已结束",
+ "endedAgo": "{{value0}}前结束"
}
},
"dashboardPopout": {
From 17be56e750dbaf74e64b7caeb10e8b80a9092732 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 23 Sep 2026 14:05:48 -0700
Subject: [PATCH 04/11] test(sidebar): a child reads the same in the sidebar
and the chat strip
A row-model table for every display state, and a parity table that renders the
same child views through the compact sidebar row and the chat strip and asserts
both show the same dot, name and detail. Covers a finished subagent whose shell
still runs (monitoring on both, no stale tool text), sibling rows with their own
clocks, a settled row timed from when it ended, and owned shells nested under
their owner in the strip. The strip's view input is named childViews so it cannot
be mistaken for React children.
---
.../agent-child-row-parity.test.tsx | 385 ++++++++++++++++++
.../NativeChatBackgroundTasksStatus.tsx | 8 +-
src/shared/agent-child-row-model.test.ts | 272 +++++++++++++
3 files changed, 661 insertions(+), 4 deletions(-)
create mode 100644 src/renderer/src/components/agent-child-row-parity.test.tsx
create mode 100644 src/shared/agent-child-row-model.test.ts
diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx
new file mode 100644
index 000000000000..9462de487c99
--- /dev/null
+++ b/src/renderer/src/components/agent-child-row-parity.test.tsx
@@ -0,0 +1,385 @@
+/** @vitest-environment happy-dom */
+import { renderToStaticMarkup } from 'react-dom/server'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
+import type { AgentStatusEntry } from '../../../shared/agent-status-types'
+import type { TerminalTab } from '../../../shared/terminal-tab-types'
+import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow'
+import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus'
+import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agent-row'
+import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows'
+import { TooltipProvider } from '@/components/ui/tooltip'
+
+vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({
+ useAgentRowConversationName: () => null
+}))
+
+vi.mock('@/components/sidebar/CacheTimer', () => ({
+ default: () => null,
+ usePromptCacheCountdownForPane: () => null
+}))
+
+const NOW = 1_000_000
+const MINUTE = 60_000
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(NOW)
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+const tab: TerminalTab = {
+ id: 'parent-tab',
+ ptyId: null,
+ worktreeId: 'wt-1',
+ title: 'Parent',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+}
+
+function view(id: string, overrides: Partial = {}): AgentChildWorkView {
+ return {
+ id,
+ providerId: `task-${id}`,
+ kind: 'agent',
+ description: 'Audit the parser',
+ agentType: 'general-purpose',
+ state: 'working',
+ membership: 'live',
+ firstObservedAt: NOW - 5 * MINUTE,
+ observedAt: NOW - 2 * MINUTE,
+ stoppable: true,
+ invocation: { invocationId: `spawn-${id}`, generation: 1 },
+ ...overrides
+ }
+}
+
+function settled(
+ outcome: NonNullable,
+ overrides: Partial = {}
+): AgentChildWorkView {
+ return view('child', {
+ state: 'done',
+ membership: 'settled',
+ outcome,
+ settledAt: NOW - 3 * MINUTE,
+ ...overrides
+ })
+}
+
+const OWNED_SHELL = view('shell', {
+ kind: 'command',
+ description: 'npm run dev',
+ agentType: undefined,
+ state: 'monitoring',
+ parentChildWorkId: 'child'
+})
+
+function parentWith(children: AgentChildWorkView[], updatedAt = NOW): AgentStatusEntry {
+ return {
+ paneKey: 'parent-tab:leaf-1',
+ tabId: tab.id,
+ worktreeId: tab.worktreeId,
+ state: 'done',
+ prompt: 'parent prompt',
+ updatedAt,
+ stateStartedAt: NOW - 20 * MINUTE,
+ stateHistory: [],
+ children
+ }
+}
+
+type RenderedRow = { dot: string; lead: string; trail: string }
+
+function readRow(root: Element, separator: string): RenderedRow {
+ const text = root.querySelector('span.truncate')
+ const [lead, trail] = [...(text?.children ?? [])].map((span) => span.textContent ?? '')
+ return {
+ dot: root.querySelector('[aria-label]')?.getAttribute('aria-label') ?? '',
+ lead: lead ?? '',
+ trail: trail?.startsWith(separator) ? trail.slice(separator.length) : (trail ?? '')
+ }
+}
+
+function mount(markup: string): HTMLElement {
+ const container = document.createElement('div')
+ container.innerHTML = markup
+ return container
+}
+
+function sidebarRows(parent: AgentStatusEntry, parentIsFresh = true): RenderedRow[] {
+ return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) =>
+ readRow(
+ mount(
+ renderToStaticMarkup(
+
+ {}} />
+
+ )
+ ),
+ ' - '
+ )
+ )
+}
+
+function stripRows(children: AgentChildWorkView[]): RenderedRow[] {
+ const root = mount(
+ renderToStaticMarkup(
+ {}}
+ onStop={() => {}}
+ />
+ )
+ )
+ return [...root.querySelectorAll('li')]
+ .filter((row) => row.querySelector(':scope > span.truncate'))
+ .map((row) => readRow(row, ' · '))
+}
+
+const SCENARIOS: [string, AgentChildWorkView[], RenderedRow][] = [
+ [
+ 'working, no known operation',
+ [view('child')],
+ { dot: 'Working', lead: 'Audit the parser', trail: 'general-purpose' }
+ ],
+ [
+ 'working with a tool',
+ [
+ view('child', {
+ operation: { toolName: 'Read', input: 'src/parser.ts', basis: 'open', observedAt: NOW }
+ })
+ ],
+ { dot: 'Working', lead: 'Audit the parser', trail: 'Read: src/parser.ts' }
+ ],
+ [
+ 'running a shell in the foreground',
+ [
+ view('child', {
+ operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: NOW }
+ })
+ ],
+ { dot: 'Working', lead: 'Audit the parser', trail: 'Bash: npm test' }
+ ],
+ [
+ 'finished, while a shell it launched still runs',
+ [settled('succeeded', { lastMessage: 'All green' }), OWNED_SHELL],
+ {
+ dot: 'Monitoring background tasks',
+ lead: 'Monitoring background tasks',
+ trail: 'Audit the parser'
+ }
+ ],
+ [
+ 'waiting on an approval',
+ [
+ view('child', {
+ state: 'waiting',
+ operation: { toolName: 'Edit', input: 'src/parser.ts', basis: 'open', observedAt: NOW }
+ })
+ ],
+ { dot: 'Waiting for input', lead: 'Audit the parser', trail: 'Edit: src/parser.ts' }
+ ],
+ [
+ 'blocked',
+ [view('child', { state: 'blocked', lastMessage: 'Rate limited, retrying' })],
+ { dot: 'Blocked', lead: 'Audit the parser', trail: 'Rate limited, retrying' }
+ ],
+ [
+ 'finished',
+ [settled('succeeded', { lastMessage: 'Found 3 call sites' })],
+ { dot: 'Done', lead: 'Audit the parser', trail: 'Found 3 call sites' }
+ ],
+ [
+ 'failed',
+ [settled('failed', { lastMessage: 'Exit code 1' })],
+ { dot: 'Failed', lead: 'Audit the parser', trail: 'Exit code 1' }
+ ],
+ [
+ 'cancelled',
+ [settled('cancelled')],
+ { dot: 'Interrupted', lead: 'Audit the parser', trail: 'general-purpose' }
+ ],
+ [
+ 'ended, outcome unknown',
+ [settled('unknown')],
+ { dot: 'Idle', lead: 'Audit the parser', trail: 'Ended' }
+ ],
+ [
+ 'unverifiable',
+ [view('child', { state: 'unverifiable' })],
+ { dot: 'No recent update', lead: 'Audit the parser', trail: 'No update in 2m' }
+ ]
+]
+
+describe('a child reads the same in the sidebar and the chat strip', () => {
+ it.each(SCENARIOS)('%s', (_name, children, expected) => {
+ const [sidebar] = sidebarRows(parentWith(children))
+ const [strip] = stripRows(children)
+ expect(sidebar).toEqual(expected)
+ expect(strip).toEqual(expected)
+ })
+
+ it('shows the monitoring icon on the full sidebar row too', () => {
+ const [agent] = buildSubagentChildRows({
+ parentEntry: parentWith([settled('succeeded'), OWNED_SHELL]),
+ tab,
+ parentIsFresh: true
+ })
+ const root = mount(
+ renderToStaticMarkup(
+
+ {}}
+ onDismiss={() => {}}
+ stateDotSize="sm"
+ hideExpand
+ />
+
+ )
+ )
+ expect(root.querySelector('[aria-label]')?.getAttribute('aria-label')).toBe(
+ 'Monitoring background tasks'
+ )
+ // The finished child's last tool line is stale; neither row names it.
+ expect(root.textContent).not.toContain('Bash')
+ })
+
+ it('never shows a monitoring child tool text on either surface', () => {
+ const children = [
+ settled('succeeded', { lastMessage: 'Bash: npm test finished' }),
+ { ...OWNED_SHELL, description: 'tail -f server.log' }
+ ]
+ const [sidebar] = sidebarRows(parentWith(children))
+ const [strip] = stripRows(children)
+ for (const row of [sidebar, strip]) {
+ expect(row.dot).toBe('Monitoring background tasks')
+ expect(`${row.lead} ${row.trail}`).not.toContain('npm test')
+ }
+ })
+})
+
+describe('sibling child rows keep their own clocks', () => {
+ it('reads each sibling from its own evidence, not the parent clock', () => {
+ const parent = parentWith(
+ [
+ view('busy', { description: 'Busy child', observedAt: NOW - 30_000 }),
+ view('quiet', {
+ description: 'Quiet child',
+ firstObservedAt: NOW - 15 * MINUTE,
+ observedAt: NOW - 10 * MINUTE
+ })
+ ],
+ NOW - 40 * MINUTE
+ )
+ const rows = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: false })
+ expect(rows.map((row) => row.entry.evidenceObservedAt)).toEqual([
+ NOW - 30_000,
+ NOW - 10 * MINUTE
+ ])
+ expect(rows.map((row) => row.startedAt)).toEqual([NOW - 5 * MINUTE, NOW - 15 * MINUTE])
+ expect(sidebarRows(parent, false).map((row) => row.trail)).toEqual([
+ 'No update in 0m',
+ 'No update in 10m'
+ ])
+ })
+
+ it('times a settled child from when it ended', () => {
+ const [agent] = buildSubagentChildRows({
+ parentEntry: parentWith([settled('succeeded')]),
+ tab,
+ parentIsFresh: true
+ })
+ const text = mount(
+ renderToStaticMarkup(
+
+ {}} />
+
+ )
+ ).textContent
+ expect(text?.endsWith('3m')).toBe(true)
+ const strip = mount(
+ renderToStaticMarkup(
+ {}}
+ onStop={() => {}}
+ />
+ )
+ )
+ expect(strip.querySelector('li')?.textContent).toContain('ended 3m 0s ago')
+ })
+})
+
+describe('the chat strip from views', () => {
+ it('nests a child-owned shell under its owner and stops by the provider id', () => {
+ const children = [
+ view('child', { description: 'Dev server owner' }),
+ view('shell', {
+ kind: 'command',
+ description: 'npm run dev',
+ agentType: undefined,
+ state: 'monitoring',
+ parentChildWorkId: 'child'
+ }),
+ view('main-shell', {
+ kind: 'command',
+ description: 'tail -f log',
+ agentType: undefined,
+ state: 'monitoring'
+ })
+ ]
+ const root = mount(
+ renderToStaticMarkup(
+ {}}
+ onStop={() => {}}
+ />
+ )
+ )
+ const groups = [...root.querySelectorAll('ul[aria-label]')].map((list) =>
+ list.getAttribute('aria-label')
+ )
+ expect(groups).toEqual(['Agents', 'Shell'])
+ const nested = root.querySelector('ul[aria-label="Agents"] ul')
+ expect(nested?.textContent).toContain('npm run dev')
+ expect(root.querySelector('ul[aria-label="Shell"]')?.textContent).not.toContain('npm run dev')
+ const stopShell = root.querySelector('button[aria-label="Stop npm run dev"]')
+ expect(stopShell?.hasAttribute('disabled')).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
index 4255b125043c..a2a4a57c33a2 100644
--- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
@@ -159,7 +159,7 @@ export function NativeChatBackgroundTasksStatus(props: {
tasks: readonly AgentSessionBackgroundTask[]
settledTasks: readonly AgentSessionBackgroundTask[]
/** The host's child views, when it publishes them; rows then read these instead of the tasks. */
- children?: readonly AgentChildWorkView[]
+ childViews?: readonly AgentChildWorkView[]
supportsTaskStop: boolean
/** False when the provider exposes no honest stop at all; the fallback
* control is hidden rather than offering a button that cannot act. */
@@ -186,10 +186,10 @@ export function NativeChatBackgroundTasksStatus(props: {
// The 1 Hz elapsed tick must not re-group, re-sort and re-translate the whole roster.
const groups: BackgroundTaskGroup[] = useMemo(
() =>
- props.children !== undefined
- ? buildBackgroundTaskGroupsFromViews(props.children)
+ props.childViews !== undefined
+ ? buildBackgroundTaskGroupsFromViews(props.childViews)
: buildBackgroundTaskGroups(props.tasks, props.settledTasks),
- [props.children, props.tasks, props.settledTasks]
+ [props.childViews, props.tasks, props.settledTasks]
)
const singleLiveCommand =
groups.length === 1 && groups[0].kind === 'command' && groups[0].tasks.length === 1
diff --git a/src/shared/agent-child-row-model.test.ts b/src/shared/agent-child-row-model.test.ts
new file mode 100644
index 000000000000..125deb5ac29e
--- /dev/null
+++ b/src/shared/agent-child-row-model.test.ts
@@ -0,0 +1,272 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildAgentChildRowModels,
+ buildLegacyAgentChildRowModels,
+ flattenAgentChildRowModels,
+ type AgentChildRowContext,
+ type AgentChildRowModel
+} from './agent-child-row-model'
+import type { AgentChildWorkView } from './agent-status-child-work-view'
+
+const FRESH: AgentChildRowContext = {
+ parentEvidenceFresh: true,
+ transportObservation: 'live',
+ parentObservedAt: 900
+}
+const STALE: AgentChildRowContext = { ...FRESH, parentEvidenceFresh: false }
+const LOST: AgentChildRowContext = { ...FRESH, transportObservation: 'unverifiable' }
+
+function view(id: string, overrides: Partial = {}): AgentChildWorkView {
+ return {
+ id,
+ providerId: `task-${id}`,
+ kind: 'agent',
+ description: `child ${id}`,
+ agentType: 'general-purpose',
+ state: 'working',
+ membership: 'live',
+ firstObservedAt: 100,
+ observedAt: 500,
+ stoppable: true,
+ invocation: { invocationId: `spawn-${id}`, generation: 1 },
+ ...overrides
+ }
+}
+
+function settled(
+ id: string,
+ outcome: NonNullable,
+ overrides: Partial = {}
+): AgentChildWorkView {
+ return view(id, {
+ state: 'done',
+ membership: 'settled',
+ outcome,
+ settledAt: 450,
+ ...overrides
+ })
+}
+
+function shell(id: string, owner: string, overrides: Partial = {}) {
+ return view(id, {
+ kind: 'command',
+ description: 'npm run dev',
+ agentType: undefined,
+ state: 'monitoring',
+ parentChildWorkId: owner,
+ ...overrides
+ })
+}
+
+function only(views: AgentChildWorkView[], context = FRESH): AgentChildRowModel {
+ const [row] = buildAgentChildRowModels(views, context)
+ return row
+}
+
+describe('buildAgentChildRowModels: the display vocabulary', () => {
+ it.each<[string, AgentChildWorkView[], Pick]>([
+ [
+ 'working with no known operation names its role',
+ [view('a')],
+ { displayState: 'working', detail: { kind: 'role', agentType: 'general-purpose' } }
+ ],
+ [
+ 'working with a foreground tool names the tool the way a CLI row does',
+ [view('a', { operation: { toolName: 'Read', basis: 'open', observedAt: 400 } })],
+ { displayState: 'working', detail: { kind: 'operation', toolName: 'Read' } }
+ ],
+ [
+ 'a foreground shell reads as working with its command',
+ [
+ view('a', {
+ operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: 400 }
+ })
+ ],
+ {
+ displayState: 'working',
+ detail: { kind: 'operation', toolName: 'Bash', input: 'npm test' }
+ }
+ ],
+ [
+ 'an idle child whose own shell runs reads monitoring',
+ [view('a', { state: 'idle' }), shell('s', 'a')],
+ { displayState: 'monitoring', detail: { kind: 'monitoring' } }
+ ],
+ [
+ 'a finished child whose own shell runs reads monitoring',
+ [settled('a', 'succeeded', { lastMessage: 'All green' }), shell('s', 'a')],
+ { displayState: 'monitoring', detail: { kind: 'monitoring' } }
+ ],
+ [
+ 'waiting names the tool the approval is for',
+ [
+ view('a', {
+ state: 'waiting',
+ operation: { toolName: 'Edit', input: 'src/a.ts', basis: 'open', observedAt: 400 }
+ })
+ ],
+ {
+ displayState: 'waiting',
+ detail: { kind: 'operation', toolName: 'Edit', input: 'src/a.ts' }
+ }
+ ],
+ [
+ 'blocked says why',
+ [view('a', { state: 'blocked', lastMessage: 'Rate limited' })],
+ { displayState: 'blocked', detail: { kind: 'message', text: 'Rate limited' } }
+ ],
+ [
+ 'succeeded reads done with its last message',
+ [settled('a', 'succeeded', { lastMessage: 'Found 3 call sites' })],
+ { displayState: 'done', detail: { kind: 'message', text: 'Found 3 call sites' } }
+ ],
+ [
+ 'failed reads failed with its error',
+ [settled('a', 'failed', { lastMessage: 'Exit code 1' })],
+ { displayState: 'failed', detail: { kind: 'message', text: 'Exit code 1' } }
+ ],
+ [
+ 'cancelled reads interrupted and adds no message',
+ [settled('a', 'cancelled', { lastMessage: 'half a thought' })],
+ { displayState: 'interrupted', detail: { kind: 'role', agentType: 'general-purpose' } }
+ ],
+ [
+ 'an ending the lane cannot classify is neutral and says Ended',
+ [settled('a', 'unknown')],
+ { displayState: 'idle', detail: { kind: 'ended' } }
+ ],
+ [
+ 'a parked live child names its role',
+ [view('a', { state: 'idle' })],
+ { displayState: 'idle', detail: { kind: 'role', agentType: 'general-purpose' } }
+ ],
+ [
+ 'a host-reported unverifiable child reports its silence',
+ [view('a', { state: 'unverifiable' })],
+ { displayState: 'unverifiable', detail: { kind: 'no-update' } }
+ ]
+ ])('%s', (_name, views, expected) => {
+ expect(only(views)).toMatchObject(expected)
+ })
+
+ it('suppresses the tool line while monitoring, even when a stale operation arrives', () => {
+ const row = only([
+ view('a', {
+ state: 'idle',
+ operation: { toolName: 'Bash', input: 'npm test', basis: 'reported', observedAt: 400 }
+ }),
+ shell('s', 'a')
+ ])
+ expect(row.displayState).toBe('monitoring')
+ expect(row.detail).toEqual({ kind: 'monitoring' })
+ })
+
+ it('makes every live claim unverifiable under a stale parent or a lost transport', () => {
+ for (const context of [STALE, LOST]) {
+ expect(only([view('a')], context)).toMatchObject({
+ displayState: 'unverifiable',
+ detail: { kind: 'no-update' }
+ })
+ // A finished child's monitoring is a claim about live work; its outcome is history.
+ expect(only([settled('a', 'succeeded'), shell('s', 'a')], context).displayState).toBe(
+ 'unverifiable'
+ )
+ expect(only([settled('a', 'failed')], context).displayState).toBe('failed')
+ expect(only([view('a', { state: 'idle' })], context).displayState).toBe('idle')
+ }
+ })
+
+ it('gives sibling rows their own clocks, never the parent one', () => {
+ const [busy, quiet] = buildAgentChildRowModels(
+ [view('busy', { observedAt: 880 }), view('quiet', { firstObservedAt: 200, observedAt: 300 })],
+ STALE
+ )
+ expect([busy.recencyAt, quiet.recencyAt]).toEqual([880, 300])
+ expect([busy.observedAt, quiet.observedAt]).toEqual([880, 300])
+ expect([busy.firstObservedAt, quiet.firstObservedAt]).toEqual([100, 200])
+ })
+
+ it('nests owned work under its owner and keeps host order', () => {
+ const rows = buildAgentChildRowModels(
+ [
+ view('a'),
+ shell('s', 'a', { state: 'working' }),
+ view('b'),
+ view('grandchild', { parentChildWorkId: 'a' })
+ ],
+ FRESH
+ )
+ expect(rows.map((row) => row.id)).toEqual(['a', 'b'])
+ expect(rows[0].owned.map((row) => row.id)).toEqual(['s', 'grandchild'])
+ expect(flattenAgentChildRowModels(rows).map((row) => row.id)).toEqual([
+ 'a',
+ 's',
+ 'grandchild',
+ 'b'
+ ])
+ // A shell names itself; only an agent's own tool line can go stale.
+ expect(rows[0].owned[0]).toMatchObject({ displayState: 'working', detail: null })
+ })
+
+ it('names a row by its label and omits a role that restates it', () => {
+ expect(
+ only([view('a', { description: ' ', name: 'subagent', agentType: 'Explore' })])
+ ).toMatchObject({ name: 'Explore', detail: null })
+ expect(only([view('a', { description: undefined, agentType: undefined })])).toMatchObject({
+ name: '',
+ detail: { kind: 'role', agentType: 'unknown' }
+ })
+ })
+
+ it('offers a stop only for a live row the host can target', () => {
+ expect(only([view('a')]).canStop).toBe(true)
+ expect(only([view('a', { providerId: undefined })]).canStop).toBe(false)
+ expect(only([view('a', { stoppable: false })]).canStop).toBe(false)
+ expect(only([settled('a', 'succeeded')]).canStop).toBe(false)
+ })
+})
+
+describe('buildLegacyAgentChildRowModels', () => {
+ it('reads a legacy snapshot as before: no clock of its own, no outcome, no operation', () => {
+ const rows = buildLegacyAgentChildRowModels(
+ [
+ { id: 'w', state: 'working', startedAt: 10, description: 'Review', agentType: 'Explore' },
+ { id: 'i', state: 'idle', startedAt: 20, agentType: 'writer' },
+ { id: 'u', state: 'unverifiable', startedAt: 30 }
+ ],
+ FRESH
+ )
+ expect(rows).toEqual([
+ expect.objectContaining({
+ id: 'w',
+ providerId: 'w',
+ displayState: 'working',
+ name: 'Review',
+ detail: { kind: 'role', agentType: 'Explore' },
+ firstObservedAt: 10,
+ recencyAt: 900,
+ settled: false,
+ canStop: false
+ }),
+ expect.objectContaining({ id: 'i', displayState: 'idle', name: 'writer', detail: null }),
+ expect.objectContaining({
+ id: 'u',
+ displayState: 'unverifiable',
+ detail: { kind: 'no-update' }
+ })
+ ])
+ expect(rows.every((row) => row.observedAt === undefined)).toBe(true)
+ })
+
+ it('decays live legacy states under a stale parent, as before', () => {
+ const rows = buildLegacyAgentChildRowModels(
+ [
+ { id: 'w', state: 'working', startedAt: 10 },
+ { id: 'q', state: 'waiting', startedAt: 10 },
+ { id: 'i', state: 'idle', startedAt: 10 }
+ ],
+ STALE
+ )
+ expect(rows.map((row) => row.displayState)).toEqual(['unverifiable', 'unverifiable', 'idle'])
+ })
+})
From 0fb1a3c5799b204872d874d6f8ab93470d6318b0 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 23 Sep 2026 14:16:51 -0700
Subject: [PATCH 05/11] fix(sidebar): keep the child display derivation
loadable in the renderer
The row model imported deriveAgentChildDisplayState from the view module, whose
owner resolution reaches status subjects and, through them, agent-hook-relay's
node:crypto. The renderer cannot evaluate that chunk, so the app booted blank (the
renderer node-builtin boundary test fails on the previous commit).
The display derivation (agentChildWorkOwnedLiveness, deriveAgentChildDisplayState,
AgentChildDisplayState) now lives in agent-status-child-work-display.ts, which
imports only the fold, liveness and the one-pass grouping both modules share
(grouped-by.ts); the view module keeps the host-side projection.
---
.../codex/codex-child-work-evidence.test.ts | 6 +-
...dex-structured-child-work-producer.test.ts | 6 +-
.../native-chat/background-task-roster.ts | 6 +-
.../sidebar/worktree-subagent-child-rows.ts | 2 +-
src/shared/agent-child-row-model.ts | 6 +-
src/shared/agent-status-child-work-display.ts | 90 +++++++++++++++++
.../agent-status-child-work-view.test.ts | 4 +-
src/shared/agent-status-child-work-view.ts | 99 +------------------
src/shared/grouped-by.ts | 14 +++
9 files changed, 120 insertions(+), 113 deletions(-)
create mode 100644 src/shared/agent-status-child-work-display.ts
create mode 100644 src/shared/grouped-by.ts
diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts
index 0f3605bee228..9013b07a6745 100644
--- a/src/main/codex/codex-child-work-evidence.test.ts
+++ b/src/main/codex/codex-child-work-evidence.test.ts
@@ -5,9 +5,9 @@ import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-wor
import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation'
import {
agentChildWorkOwnedLiveness,
- deriveAgentChildDisplayState,
- projectAgentChildWorkViews
-} from '../../shared/agent-status-child-work-view'
+ deriveAgentChildDisplayState
+} from '../../shared/agent-status-child-work-display'
+import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view'
import { createAgentStatusStore } from '../../shared/agent-status-store'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import type { CodexBackgroundTaskEvent } from './codex-background-task-frames'
diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts
index 44d4463a30d7..fc011d48bb9e 100644
--- a/src/main/codex/codex-structured-child-work-producer.test.ts
+++ b/src/main/codex/codex-structured-child-work-producer.test.ts
@@ -12,9 +12,9 @@ import { agentChildWorkLiveness } from '../../shared/agent-status-child-work-liv
import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation'
import {
agentChildWorkOwnedLiveness,
- deriveAgentChildDisplayState,
- projectAgentChildWorkViews
-} from '../../shared/agent-status-child-work-view'
+ deriveAgentChildDisplayState
+} from '../../shared/agent-status-child-work-display'
+import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view'
import { createAgentStatusStore } from '../../shared/agent-status-store'
import { agentJournalLinkageFields } from '../../shared/agent-session-journal-producer'
import type {
diff --git a/src/renderer/src/components/native-chat/background-task-roster.ts b/src/renderer/src/components/native-chat/background-task-roster.ts
index 32cda7cad1a1..61d85174cd7c 100644
--- a/src/renderer/src/components/native-chat/background-task-roster.ts
+++ b/src/renderer/src/components/native-chat/background-task-roster.ts
@@ -11,10 +11,8 @@ import {
type AgentChildRowContext,
type AgentChildRowModel
} from '../../../../shared/agent-child-row-model'
-import type {
- AgentChildDisplayState,
- AgentChildWorkView
-} from '../../../../shared/agent-status-child-work-view'
+import type { AgentChildDisplayState } from '../../../../shared/agent-status-child-work-display'
+import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
import { formatNativeChatDuration } from '../../../../shared/native-chat-turn-status'
import { translate } from '@/i18n/i18n'
diff --git a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
index 3a4ec7f88463..f603cbd39441 100644
--- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
+++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
@@ -7,7 +7,7 @@ import {
flattenAgentChildRowModels,
type AgentChildRowModel
} from '../../../../shared/agent-child-row-model'
-import type { AgentChildDisplayState } from '../../../../shared/agent-status-child-work-view'
+import type { AgentChildDisplayState } from '../../../../shared/agent-status-child-work-display'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
/** Row-identity key for an in-process subagent child row. The NUL separator
diff --git a/src/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts
index 0c8f75b764e7..9bcfcec5b9a3 100644
--- a/src/shared/agent-child-row-model.ts
+++ b/src/shared/agent-child-row-model.ts
@@ -3,9 +3,9 @@ import { resolveAgentChildWorkFreshness } from './agent-status-child-work-freshn
import {
agentChildWorkOwnedLiveness,
deriveAgentChildDisplayState,
- type AgentChildDisplayState,
- type AgentChildWorkView
-} from './agent-status-child-work-view'
+ type AgentChildDisplayState
+} from './agent-status-child-work-display'
+import type { AgentChildWorkView } from './agent-status-child-work-view'
import type { AgentSubagentSnapshot } from './agent-status-types'
/** What a child row says beside its name. Surfaces format it; they never decide it. */
diff --git a/src/shared/agent-status-child-work-display.ts b/src/shared/agent-status-child-work-display.ts
new file mode 100644
index 000000000000..9dd75de4c736
--- /dev/null
+++ b/src/shared/agent-status-child-work-display.ts
@@ -0,0 +1,90 @@
+import { foldAgentLeadStatus } from './agent-lead-status-fold'
+import type { AgentChildWorkId, AgentChildWorkOutcome } from './agent-status-child-work'
+import {
+ agentChildWorkLiveness,
+ type AgentChildWorkLiveness
+} from './agent-status-child-work-liveness'
+import type { AgentChildWorkView } from './agent-status-child-work-view'
+import type { AgentStatusState } from './agent-status-types'
+import { groupedBy } from './grouped-by'
+
+// What a surface derives from child views. Kept apart from the projection, which resolves owners
+// against status subjects and so reaches host-only code: every surface, renderer included, loads
+// this module.
+
+type AgentChildWorkOwnershipView = Pick<
+ AgentChildWorkView,
+ 'id' | 'kind' | 'state' | 'membership' | 'parentChildWorkId'
+>
+
+/** Liveness of all live work beneath a child, at any depth — the same input a parent row folds. */
+export function agentChildWorkOwnedLiveness(
+ views: readonly AgentChildWorkOwnershipView[],
+ ownerId: AgentChildWorkId
+): AgentChildWorkLiveness {
+ const owned = groupedBy(views, (view) => view.parentChildWorkId)
+ const seen = new Set([ownerId])
+ const frontier = [ownerId]
+ const liveDescendants: AgentChildWorkOwnershipView[] = []
+ for (let owner = frontier.pop(); owner !== undefined; owner = frontier.pop()) {
+ for (const view of owned.get(owner) ?? []) {
+ if (!seen.has(view.id)) {
+ seen.add(view.id)
+ frontier.push(view.id)
+ if (view.membership === 'live') {
+ liveDescendants.push(view)
+ }
+ }
+ }
+ }
+ return agentChildWorkLiveness(liveDescendants)
+}
+
+/** The dot a child row renders; every value is an `AgentStateDot` state. */
+export type AgentChildDisplayState =
+ | 'working'
+ | 'monitoring'
+ | 'waiting'
+ | 'blocked'
+ | 'done'
+ | 'failed'
+ | 'interrupted'
+ | 'idle'
+ | 'unverifiable'
+
+const SETTLED_DISPLAY_STATE: Record = {
+ succeeded: 'done',
+ failed: 'failed',
+ cancelled: 'interrupted',
+ // Neutral: an ending the lane cannot classify asserts nothing.
+ unknown: 'idle'
+}
+
+/**
+ * A child's display state, through the same fold that decides a parent row's: work that is idle
+ * or finished enters it as `done`, so a live shell the child owns reads `monitoring` exactly as it
+ * would under a CLI agent. `unverifiable` is a freshness verdict and bypasses the fold.
+ */
+export function deriveAgentChildDisplayState(
+ view: Pick,
+ ownedLiveness: AgentChildWorkLiveness
+): AgentChildDisplayState {
+ if (view.state === 'unverifiable') {
+ return 'unverifiable'
+ }
+ // Stored only by a shell or a monitor, and neither owns work.
+ if (view.state === 'monitoring') {
+ return 'monitoring'
+ }
+ const leadState: AgentStatusState =
+ view.membership === 'settled' || view.state === 'done' || view.state === 'idle'
+ ? 'done'
+ : view.state
+ // A child's cancel never hides the work it left running.
+ const foldInput = { leadState, childWorkLiveness: ownedLiveness, interrupted: false }
+ const folded = foldAgentLeadStatus(foldInput)
+ if (folded.stateName !== 'done') {
+ return folded.workingMode ?? folded.stateName
+ }
+ return view.membership === 'live' ? 'idle' : SETTLED_DISPLAY_STATE[view.outcome ?? 'unknown']
+}
diff --git a/src/shared/agent-status-child-work-view.test.ts b/src/shared/agent-status-child-work-view.test.ts
index 9c79a5451835..da0de4e752cd 100644
--- a/src/shared/agent-status-child-work-view.test.ts
+++ b/src/shared/agent-status-child-work-view.test.ts
@@ -15,7 +15,9 @@ import {
} from './agent-status-child-work-projection'
import {
agentChildWorkOwnedLiveness,
- deriveAgentChildDisplayState,
+ deriveAgentChildDisplayState
+} from './agent-status-child-work-display'
+import {
projectAgentChildWorkViews,
type AgentChildWorkView,
type AgentChildWorkViewAlias
diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts
index 8c70f6a3fea9..bfcbcfc11d5a 100644
--- a/src/shared/agent-status-child-work-view.ts
+++ b/src/shared/agent-status-child-work-view.ts
@@ -1,4 +1,3 @@
-import { foldAgentLeadStatus } from './agent-lead-status-fold'
import {
AGENT_CHILD_WORK_ALIAS_KINDS,
type AgentChildWorkAliasInput,
@@ -15,12 +14,8 @@ import {
type AgentChildWorkOutcome,
type AgentChildWorkState
} from './agent-status-child-work'
-import {
- agentChildWorkLiveness,
- type AgentChildWorkLiveness
-} from './agent-status-child-work-liveness'
import { agentStatusSubjectsEqual } from './agent-status-subject'
-import type { AgentStatusState } from './agent-status-types'
+import { groupedBy } from './grouped-by'
/** What a surface reads about one child: a read-only projection of the host's record.
* Host bookkeeping (residency, invocation history, provenance, aliases) never travels. A view
@@ -83,21 +78,6 @@ function providerIdFor(
return undefined
}
-// Not `Map.groupBy`: the relay runs this core on Node 18, which lacks it.
-function groupedBy(items: readonly T[], keyOf: (item: T) => K): Map {
- const groups = new Map()
- for (const item of items) {
- const key = keyOf(item)
- const group = groups.get(key)
- if (group) {
- group.push(item)
- } else {
- groups.set(key, [item])
- }
- }
- return groups
-}
-
function isOnOwnershipCycle(
start: AgentChildWorkInput,
byId: ReadonlyMap
@@ -161,80 +141,3 @@ export function projectAgentChildWorkViews(
}
})
}
-
-type AgentChildWorkOwnershipView = Pick<
- AgentChildWorkView,
- 'id' | 'kind' | 'state' | 'membership' | 'parentChildWorkId'
->
-
-/** Liveness of all live work beneath a child, at any depth — the same input a parent row folds. */
-export function agentChildWorkOwnedLiveness(
- views: readonly AgentChildWorkOwnershipView[],
- ownerId: AgentChildWorkId
-): AgentChildWorkLiveness {
- const owned = groupedBy(views, (view) => view.parentChildWorkId)
- const seen = new Set([ownerId])
- const frontier = [ownerId]
- const liveDescendants: AgentChildWorkOwnershipView[] = []
- for (let owner = frontier.pop(); owner !== undefined; owner = frontier.pop()) {
- for (const view of owned.get(owner) ?? []) {
- if (!seen.has(view.id)) {
- seen.add(view.id)
- frontier.push(view.id)
- if (view.membership === 'live') {
- liveDescendants.push(view)
- }
- }
- }
- }
- return agentChildWorkLiveness(liveDescendants)
-}
-
-/** The dot a child row renders; every value is an `AgentStateDot` state. */
-export type AgentChildDisplayState =
- | 'working'
- | 'monitoring'
- | 'waiting'
- | 'blocked'
- | 'done'
- | 'failed'
- | 'interrupted'
- | 'idle'
- | 'unverifiable'
-
-const SETTLED_DISPLAY_STATE: Record = {
- succeeded: 'done',
- failed: 'failed',
- cancelled: 'interrupted',
- // Neutral: an ending the lane cannot classify asserts nothing.
- unknown: 'idle'
-}
-
-/**
- * A child's display state, through the same fold that decides a parent row's: work that is idle
- * or finished enters it as `done`, so a live shell the child owns reads `monitoring` exactly as it
- * would under a CLI agent. `unverifiable` is a freshness verdict and bypasses the fold.
- */
-export function deriveAgentChildDisplayState(
- view: Pick,
- ownedLiveness: AgentChildWorkLiveness
-): AgentChildDisplayState {
- if (view.state === 'unverifiable') {
- return 'unverifiable'
- }
- // Stored only by a shell or a monitor, and neither owns work.
- if (view.state === 'monitoring') {
- return 'monitoring'
- }
- const leadState: AgentStatusState =
- view.membership === 'settled' || view.state === 'done' || view.state === 'idle'
- ? 'done'
- : view.state
- // A child's cancel never hides the work it left running.
- const foldInput = { leadState, childWorkLiveness: ownedLiveness, interrupted: false }
- const folded = foldAgentLeadStatus(foldInput)
- if (folded.stateName !== 'done') {
- return folded.workingMode ?? folded.stateName
- }
- return view.membership === 'live' ? 'idle' : SETTLED_DISPLAY_STATE[view.outcome ?? 'unknown']
-}
diff --git a/src/shared/grouped-by.ts b/src/shared/grouped-by.ts
new file mode 100644
index 000000000000..0797dca96201
--- /dev/null
+++ b/src/shared/grouped-by.ts
@@ -0,0 +1,14 @@
+// Not `Map.groupBy`: the relay runs this core on Node 18, which lacks it.
+export function groupedBy(items: readonly T[], keyOf: (item: T) => K): Map {
+ const groups = new Map()
+ for (const item of items) {
+ const key = keyOf(item)
+ const group = groups.get(key)
+ if (group) {
+ group.push(item)
+ } else {
+ groups.set(key, [item])
+ }
+ }
+ return groups
+}
From 4150ac38d1149b2806ba3125451c51620b48fcd7 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 23 Sep 2026 16:33:27 -0700
Subject: [PATCH 06/11] fix(sidebar): the strip reads its parent's verdict, and
the full row says Ended
Review fixes:
- The strip's view path took no freshness input, so once views are wired the same lost
child would read unverifiable in the sidebar and working in the strip.
agentChildRowContextForParent builds the one context a parent row gives its children;
the sidebar uses it, and buildBackgroundTaskGroupsFromViews / the strip's new optional
childRowContext prop accept it (absent: claims stand as reported, as before).
- The full sidebar row showed no word for a child that ended with no outcome; its message
line now reads the row model's (the message, or Ended). An unlabeled child falls back
to its display state there too.
- The summary order now includes failed, so a failed row is never dropped from the counts.
- Parity now covers the full row for every state, a live parked child, an unlabeled child,
and a lost or stale parent on both surfaces.
---
.../agent-child-row-parity.test.tsx | 112 +++++++++++++++++-
.../src/components/agent-child-row-text.ts | 8 ++
.../dashboard/DashboardAgentRow.tsx | 15 ++-
.../NativeChatBackgroundTasksStatus.tsx | 12 +-
.../native-chat/background-task-roster.ts | 12 +-
.../worktree-card-agent-summary.test.ts | 30 ++++-
.../sidebar/worktree-subagent-child-rows.ts | 7 +-
src/shared/agent-child-row-model.ts | 15 ++-
8 files changed, 190 insertions(+), 21 deletions(-)
diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx
index 9462de487c99..dec3a340fe53 100644
--- a/src/renderer/src/components/agent-child-row-parity.test.tsx
+++ b/src/renderer/src/components/agent-child-row-parity.test.tsx
@@ -1,6 +1,10 @@
/** @vitest-environment happy-dom */
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ agentChildRowContextForParent,
+ type AgentChildRowContext
+} from '../../../shared/agent-child-row-model'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
@@ -127,13 +131,17 @@ function sidebarRows(parent: AgentStatusEntry, parentIsFresh = true): RenderedRo
)
}
-function stripRows(children: AgentChildWorkView[]): RenderedRow[] {
+function stripRows(
+ children: AgentChildWorkView[],
+ childRowContext?: AgentChildRowContext
+): RenderedRow[] {
const root = mount(
renderToStaticMarkup(
readRow(row, ' · '))
}
+/** The full sidebar row: every dot label it carries, and its whole text. */
+function fullRow(parent: AgentStatusEntry): { labels: string[]; text: string } {
+ const [agent] = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: true })
+ const root = mount(
+ renderToStaticMarkup(
+
+ {}}
+ onDismiss={() => {}}
+ stateDotSize="sm"
+ hideExpand
+ />
+
+ )
+ )
+ return {
+ labels: [...root.querySelectorAll('[aria-label]')].map(
+ (element) => element.getAttribute('aria-label') ?? ''
+ ),
+ text: root.textContent ?? ''
+ }
+}
+
const SCENARIOS: [string, AgentChildWorkView[], RenderedRow][] = [
[
'working, no known operation',
@@ -223,15 +256,56 @@ const SCENARIOS: [string, AgentChildWorkView[], RenderedRow][] = [
'unverifiable',
[view('child', { state: 'unverifiable' })],
{ dot: 'No recent update', lead: 'Audit the parser', trail: 'No update in 2m' }
+ ],
+ [
+ 'parked, still live',
+ [view('child', { state: 'idle' })],
+ { dot: 'Idle', lead: 'Audit the parser', trail: 'general-purpose' }
]
]
+/** What the full sidebar row, the CLI row's own layout, shows of the same detail. */
+const FULL_ROW_DETAIL: Record = {
+ 'working, no known operation': { shows: [] },
+ 'working with a tool': { shows: ['Read', 'src/parser.ts'] },
+ 'running a shell in the foreground': { shows: ['Bash', 'npm test'] },
+ 'finished, while a shell it launched still runs': { shows: [], hides: ['All green'] },
+ 'waiting on an approval': { shows: ['Edit', 'src/parser.ts'] },
+ blocked: { shows: ['Rate limited, retrying'] },
+ finished: { shows: ['Found 3 call sites'] },
+ failed: { shows: ['Exit code 1'] },
+ cancelled: { shows: [] },
+ 'ended, outcome unknown': { shows: ['Ended'] },
+ unverifiable: { shows: ['No update in 2m'] },
+ 'parked, still live': { shows: [] }
+}
+
describe('a child reads the same in the sidebar and the chat strip', () => {
- it.each(SCENARIOS)('%s', (_name, children, expected) => {
+ it.each(SCENARIOS)('%s', (name, children, expected) => {
const [sidebar] = sidebarRows(parentWith(children))
const [strip] = stripRows(children)
expect(sidebar).toEqual(expected)
expect(strip).toEqual(expected)
+ const full = fullRow(parentWith(children))
+ expect(full.labels).toContain(expected.dot)
+ expect(full.text).toContain(expected.lead === expected.dot ? expected.trail : expected.lead)
+ for (const text of FULL_ROW_DETAIL[name].shows) {
+ expect(full.text).toContain(text)
+ }
+ for (const text of FULL_ROW_DETAIL[name].hides ?? []) {
+ expect(full.text).not.toContain(text)
+ }
+ })
+
+ it('names an unlabeled child by the same state on every surface', () => {
+ const children = [
+ settled('failed', { description: undefined, agentType: undefined, lastMessage: 'Exit 2' })
+ ]
+ const [sidebar] = sidebarRows(parentWith(children))
+ const [strip] = stripRows(children)
+ expect(sidebar.lead).toBe('Failed')
+ expect(strip.lead).toBe('Failed')
+ expect(fullRow(parentWith(children)).text).toContain('Failed')
})
it('shows the monitoring icon on the full sidebar row too', () => {
@@ -275,6 +349,40 @@ describe('a child reads the same in the sidebar and the chat strip', () => {
})
})
+describe('a lost or stale parent reads the same on both surfaces', () => {
+ const children = [
+ view('child', {
+ operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: NOW }
+ })
+ ]
+ const lost: RenderedRow = {
+ dot: 'No recent update',
+ lead: 'Audit the parser',
+ trail: 'No update in 2m'
+ }
+
+ it('when the transport to the host is lost', () => {
+ const parent = { ...parentWith(children), subagentObservation: 'unverifiable' as const }
+ const [sidebar] = sidebarRows(parent)
+ const [strip] = stripRows(children, agentChildRowContextForParent(parent, true))
+ expect(sidebar).toEqual(lost)
+ expect(strip).toEqual(lost)
+ })
+
+ it('when the parent row has gone stale', () => {
+ const parent = parentWith([settled('succeeded'), OWNED_SHELL], NOW - 40 * MINUTE)
+ const [sidebar] = sidebarRows(parent, false)
+ const [strip] = stripRows(parent.children ?? [], agentChildRowContextForParent(parent, false))
+ const stale = { dot: 'No recent update', lead: 'Audit the parser', trail: 'No update in 2m' }
+ expect(sidebar).toEqual(stale)
+ expect(strip).toEqual(stale)
+ })
+
+ it('without a context the strip reports what the host last said', () => {
+ expect(stripRows(children)[0].dot).toBe('Working')
+ })
+})
+
describe('sibling child rows keep their own clocks', () => {
it('reads each sibling from its own evidence, not the parent clock', () => {
const parent = parentWith(
diff --git a/src/renderer/src/components/agent-child-row-text.ts b/src/renderer/src/components/agent-child-row-text.ts
index 6187d8ba9554..ea6d5e9df02a 100644
--- a/src/renderer/src/components/agent-child-row-text.ts
+++ b/src/renderer/src/components/agent-child-row-text.ts
@@ -41,6 +41,14 @@ export function agentChildRowDetailText(row: AgentChildRowModel, now: number): s
}
}
+/** The line beneath a full-width row: what the child said, or that it ended; '' otherwise. */
+export function agentChildRowMessageLine(row: AgentChildRowModel): string {
+ if (row.detail?.kind === 'message') {
+ return row.detail.text
+ }
+ return row.detail?.kind === 'ended' ? translate('components.agentChildRow.ended', 'Ended') : ''
+}
+
/** The row's name, or its state when the child reported none. */
export function agentChildRowName(row: AgentChildRowModel): string {
return row.name.trim() || agentStateLabel(row.displayState)
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
index fe6ab7894b95..1d1208718ec9 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
@@ -15,6 +15,7 @@ import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardD
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
import { useAgentRowConversationName } from './use-agent-row-conversation-name'
import { lastEnteredDoneAt } from './agent-finished-timestamp'
+import { agentChildRowMessageLine } from '@/components/agent-child-row-text'
function formatTimeAgo(ts: number, now: number): string {
const delta = now - ts
@@ -127,7 +128,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
const conversationName = useAgentRowConversationName(agent)
const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry)
// Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled.
- const displayLabel = prompt || agentStateLabel(asDotState(agent.state, agent.entry.workingMode))
+ const displayLabel =
+ prompt ||
+ agentStateLabel(
+ agent.childRow?.displayState ?? asDotState(agent.state, agent.entry.workingMode)
+ )
const model = agent.entry.model?.trim() ?? ''
const isMonitoring = agent.state === 'working' && agent.entry.workingMode === 'monitoring'
const isWorking = agent.state === 'working' && !isMonitoring
@@ -137,9 +142,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring
const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : ''
const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : ''
- const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? ''
- const verdictDotState = agentVerdictDisplayMark(agent.entry)
- const isInterrupted = verdictDotState === 'interrupted'
+ // Why: a child row's message line is the model's, so a child that ended without an outcome says so.
+ const lastAssistantMessage = agent.childRow
+ ? agentChildRowMessageLine(agent.childRow)
+ : (agent.entry.lastAssistantMessage?.trim() ?? '')
+ const isInterrupted = agentVerdictDisplayMark(agent.entry) === 'interrupted'
const lineage = agent.lineage
const isLineageChild = lineage?.depth === 1
const lineageChildCount = lineage?.childCount ?? 0
diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
index a2a4a57c33a2..39b75a0f3686 100644
--- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
@@ -1,7 +1,10 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Activity, Bot, ChevronDown, CircleHelp, SquareTerminal, Workflow } from 'lucide-react'
import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
-import type { AgentChildRowModel } from '../../../../shared/agent-child-row-model'
+import type {
+ AgentChildRowContext,
+ AgentChildRowModel
+} from '../../../../shared/agent-child-row-model'
import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
import { AgentChildRowContent } from '@/components/AgentChildRowContent'
import { agentChildRowEndedLabel, agentChildRowName } from '@/components/agent-child-row-text'
@@ -160,6 +163,9 @@ export function NativeChatBackgroundTasksStatus(props: {
settledTasks: readonly AgentSessionBackgroundTask[]
/** The host's child views, when it publishes them; rows then read these instead of the tasks. */
childViews?: readonly AgentChildWorkView[]
+ /** The session's parent-row context for those views, the one its sidebar rows read; without
+ * it every live claim stands as reported. */
+ childRowContext?: AgentChildRowContext
supportsTaskStop: boolean
/** False when the provider exposes no honest stop at all; the fallback
* control is hidden rather than offering a button that cannot act. */
@@ -187,9 +193,9 @@ export function NativeChatBackgroundTasksStatus(props: {
const groups: BackgroundTaskGroup[] = useMemo(
() =>
props.childViews !== undefined
- ? buildBackgroundTaskGroupsFromViews(props.childViews)
+ ? buildBackgroundTaskGroupsFromViews(props.childViews, props.childRowContext)
: buildBackgroundTaskGroups(props.tasks, props.settledTasks),
- [props.childViews, props.tasks, props.settledTasks]
+ [props.childViews, props.childRowContext, props.tasks, props.settledTasks]
)
const singleLiveCommand =
groups.length === 1 && groups[0].kind === 'command' && groups[0].tasks.length === 1
diff --git a/src/renderer/src/components/native-chat/background-task-roster.ts b/src/renderer/src/components/native-chat/background-task-roster.ts
index 61d85174cd7c..a65147f7b750 100644
--- a/src/renderer/src/components/native-chat/background-task-roster.ts
+++ b/src/renderer/src/components/native-chat/background-task-roster.ts
@@ -132,8 +132,8 @@ export function buildBackgroundTaskGroups(
return groupRosterEntries([...owners.values()])
}
-// The strip reads a live session channel; contact loss is not yet a verdict it receives.
-const STRIP_ROW_CONTEXT: AgentChildRowContext = {
+// Until a caller passes the session's parent-row context, every live claim stands as reported.
+const REPORTED_ROW_CONTEXT: AgentChildRowContext = {
parentEvidenceFresh: true,
transportObservation: 'live',
parentObservedAt: 0
@@ -158,12 +158,14 @@ function headerRunState(displayState: AgentChildDisplayState): RunState {
}
/** Kind groups from the host's child views: the main agent's work at the top, each child's own
- * work nested beneath it rather than counted again in its kind's group. */
+ * work nested beneath it rather than counted again in its kind's group. Pass the context the
+ * sidebar builds for the same parent (`agentChildRowContextForParent`) so both read one verdict. */
export function buildBackgroundTaskGroupsFromViews(
- views: readonly AgentChildWorkView[]
+ views: readonly AgentChildWorkView[],
+ context: AgentChildRowContext = REPORTED_ROW_CONTEXT
): BackgroundTaskGroup[] {
return groupRosterEntries(
- buildAgentChildRowModels(views, STRIP_ROW_CONTEXT).map((row) => ({
+ buildAgentChildRowModels(views, context).map((row) => ({
row,
state: headerRunState(row.displayState)
}))
diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts
index 7f1215c980a5..62a7d54c6d57 100644
--- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts
@@ -4,7 +4,11 @@ import { describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { CompactAgentRow, getCompactAgentSecondary } from './worktree-card-compact-agent-row'
-import { getAgentDotState, summarizeAgents } from './worktree-card-agent-summary'
+import {
+ buildSummaryAgentGroups,
+ getAgentDotState,
+ summarizeAgents
+} from './worktree-card-agent-summary'
import { buildSubagentChildRows } from './worktree-subagent-child-rows'
function monitoringAgent(): DashboardAgentRowData {
@@ -46,6 +50,30 @@ function renderCompactAgentRow(props: React.ComponentProps {
+ it('keeps a failed child in the groups and the counts', () => {
+ const failed: DashboardAgentRowData = {
+ ...monitoringAgent(),
+ paneKey: 'tab-1:leaf-1\u0000subagent:child',
+ rowSource: 'subagent',
+ state: 'blocked',
+ childRow: {
+ id: 'child',
+ kind: 'agent',
+ displayState: 'failed',
+ name: 'Fuzz the tokenizer',
+ detail: null,
+ firstObservedAt: 1,
+ recencyAt: 1,
+ canStop: false,
+ settled: true,
+ owned: []
+ }
+ }
+ expect(getAgentDotState(failed)).toBe('failed')
+ expect(buildSummaryAgentGroups([failed]).map((group) => group.state)).toEqual(['failed'])
+ expect(summarizeAgents([failed], 'Agent')).toBe('Agent failed')
+ })
+
it('presents passive working as monitoring', () => {
const agent = monitoringAgent()
diff --git a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
index f603cbd39441..61533ebc6257 100644
--- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
+++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
@@ -2,6 +2,7 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import type { AgentRowState } from '@/lib/agent-row-decay-state'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import {
+ agentChildRowContextForParent,
buildAgentChildRowModels,
buildLegacyAgentChildRowModels,
flattenAgentChildRowModels,
@@ -104,11 +105,7 @@ export function buildSubagentChildRows(args: {
parentIsFresh: boolean
}): DashboardAgentRow[] {
const { parentEntry } = args
- const context = {
- parentEvidenceFresh: args.parentIsFresh,
- transportObservation: parentEntry.subagentObservation ?? 'live',
- parentObservedAt: parentEntry.updatedAt
- }
+ const context = agentChildRowContextForParent(parentEntry, args.parentIsFresh)
const rows =
parentEntry.children !== undefined
? buildAgentChildRowModels(parentEntry.children, context)
diff --git a/src/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts
index 9bcfcec5b9a3..281dcbcd7915 100644
--- a/src/shared/agent-child-row-model.ts
+++ b/src/shared/agent-child-row-model.ts
@@ -6,7 +6,7 @@ import {
type AgentChildDisplayState
} from './agent-status-child-work-display'
import type { AgentChildWorkView } from './agent-status-child-work-view'
-import type { AgentSubagentSnapshot } from './agent-status-types'
+import type { AgentStatusEntry, AgentSubagentSnapshot } from './agent-status-types'
/** What a child row says beside its name. Surfaces format it; they never decide it. */
export type AgentChildRowDetail =
@@ -59,6 +59,19 @@ export type AgentChildRowContext = {
parentObservedAt: number
}
+/** The context a parent row gives its children. Every surface that lists one parent's children
+ * builds it here, from the same parent row, so a lost child reads the same everywhere. */
+export function agentChildRowContextForParent(
+ parent: Pick,
+ parentEvidenceFresh: boolean
+): AgentChildRowContext {
+ return {
+ parentEvidenceFresh,
+ transportObservation: parent.subagentObservation ?? 'live',
+ parentObservedAt: parent.updatedAt
+ }
+}
+
/** Provider strings that carry no identity; the next label wins. */
const PLACEHOLDER_LABELS = new Set(['unknown', 'untitled', 'task', 'subagent'])
From 720f30613f6d08a4b2519bade5736f55d17d26e4 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 24 Sep 2026 03:04:25 -0700
Subject: [PATCH 07/11] refactor(agent-status): the subagent snapshot, its
normalization and equality get their own module
agent-status-types.ts crossed the file-size limit once the row gained child views beside
the main agent fact. The subagent snapshot shape, its admission normalization and the array
equality move into agent-status-subagent-snapshot.ts (re-exported, so importers are
unchanged); the three field caps it shares with the row move to the field normalization.
---
.../agent-status-field-normalization.ts | 7 +-
src/shared/agent-status-subagent-snapshot.ts | 108 +++++++++++++++
src/shared/agent-status-types.ts | 126 +++---------------
3 files changed, 135 insertions(+), 106 deletions(-)
create mode 100644 src/shared/agent-status-subagent-snapshot.ts
diff --git a/src/shared/agent-status-field-normalization.ts b/src/shared/agent-status-field-normalization.ts
index 25d6b6413d4e..4fb741b5ec16 100644
--- a/src/shared/agent-status-field-normalization.ts
+++ b/src/shared/agent-status-field-normalization.ts
@@ -2,12 +2,17 @@
// String normalizers shared by every agent-status payload field: trim/fold to
// a single line for previews, preserve structure for multiline bodies, and
// truncate without splitting surrogate pairs. Extracted from
-// agent-status-types.ts, which owns the payload shapes and per-field caps.
+// agent-status-types.ts, which owns the payload shapes and the caps no other module applies.
import { compactDispatchPromptForStatus } from './orca-dispatch-status-prompt'
/** Maximum character length for the prompt field. Truncated on parse. */
export const AGENT_STATUS_MAX_FIELD_LENGTH = 200
+/** Maximum character length for the toolInput preview. */
+export const AGENT_STATUS_TOOL_INPUT_MAX_LENGTH = 160
+/** Maximum character length for the agentType label. Truncated on parse. */
+export const AGENT_TYPE_MAX_LENGTH = 40
+export const AGENT_MODEL_MAX_LENGTH = 120
const SINGLE_LINE_FIELD_SCAN_OVERHEAD = 64
const SINGLE_LINE_FIELD_SCAN_MULTIPLIER = 8
diff --git a/src/shared/agent-status-subagent-snapshot.ts b/src/shared/agent-status-subagent-snapshot.ts
new file mode 100644
index 000000000000..b5a9f5af8fa4
--- /dev/null
+++ b/src/shared/agent-status-subagent-snapshot.ts
@@ -0,0 +1,108 @@
+// A pane's in-process subagents as the hook lane publishes them on a status row: the snapshot
+// shape, its normalization at every admission point, and the equality stores reuse arrays by.
+
+import {
+ AGENT_MODEL_MAX_LENGTH,
+ AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
+ AGENT_TYPE_MAX_LENGTH,
+ normalizeOptionalField
+} from './agent-status-field-normalization'
+
+export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' | 'unverifiable'
+
+/** A live in-process child of the pane's provider session. Rendered as an
+ * indented child row with no PTY of its own. */
+export type AgentSubagentSnapshot = {
+ /** Provider-assigned lifecycle id. */
+ id: string
+ agentType?: string
+ /** Provider model used by this child, when exposed by its lifecycle event. */
+ model?: string
+ description?: string
+ state: AgentSubagentState
+ /** Timestamp (ms) when this subagent was first observed. */
+ startedAt: number
+}
+
+/** Maximum subagent child rows carried per status entry. Bounds per-pane cache
+ * and IPC fanout against a runaway spawner. */
+export const AGENT_STATUS_MAX_SUBAGENTS = 32
+const AGENT_SUBAGENT_ID_MAX_LENGTH = 64
+
+function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null {
+ if (typeof value !== 'object' || value === null) {
+ return null
+ }
+ const obj = value as Record
+ if (typeof obj.id !== 'string') {
+ return null
+ }
+ const id = obj.id.trim()
+ if (id.length === 0 || id.length > AGENT_SUBAGENT_ID_MAX_LENGTH) {
+ return null
+ }
+ if (
+ obj.state !== 'working' &&
+ obj.state !== 'blocked' &&
+ obj.state !== 'waiting' &&
+ obj.state !== 'idle' &&
+ obj.state !== 'unverifiable'
+ ) {
+ return null
+ }
+ return {
+ id,
+ state: obj.state,
+ startedAt:
+ typeof obj.startedAt === 'number' && Number.isFinite(obj.startedAt) ? obj.startedAt : 0,
+ agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH),
+ model: normalizeOptionalField(obj.model, AGENT_MODEL_MAX_LENGTH),
+ description: normalizeOptionalField(obj.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH)
+ }
+}
+
+export function normalizeAgentSubagentsField(value: unknown): AgentSubagentSnapshot[] | undefined {
+ if (!Array.isArray(value) || value.length === 0) {
+ return undefined
+ }
+ const normalized: AgentSubagentSnapshot[] = []
+ for (const item of value) {
+ const snapshot = normalizeSubagentSnapshot(item)
+ if (snapshot) {
+ normalized.push(snapshot)
+ if (normalized.length >= AGENT_STATUS_MAX_SUBAGENTS) {
+ break
+ }
+ }
+ }
+ return normalized.length > 0 ? normalized : undefined
+}
+
+/** Structural equality for subagent lists so stores can reuse the previous
+ * array reference (and skip fanout) when nothing actually changed. */
+export function agentSubagentsEqual(
+ a: AgentSubagentSnapshot[] | undefined,
+ b: AgentSubagentSnapshot[] | undefined
+): boolean {
+ if (a === b) {
+ return true
+ }
+ if (!a || !b || a.length !== b.length) {
+ return !a && !b
+ }
+ for (let i = 0; i < a.length; i++) {
+ const x = a[i]
+ const y = b[i]
+ if (
+ x.id !== y.id ||
+ x.state !== y.state ||
+ x.startedAt !== y.startedAt ||
+ x.agentType !== y.agentType ||
+ x.model !== y.model ||
+ x.description !== y.description
+ ) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts
index 0a0989425f18..acc7e4f42554 100644
--- a/src/shared/agent-status-types.ts
+++ b/src/shared/agent-status-types.ts
@@ -11,6 +11,9 @@ import type { AgentStatusRowFacets } from './agent-status-observation'
import type { AgentChildWorkView } from './agent-status-child-work-view'
import type { TuiAgent } from './tui-agent'
import {
+ AGENT_MODEL_MAX_LENGTH,
+ AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
+ AGENT_TYPE_MAX_LENGTH,
normalizeInteractivePromptField,
normalizeOptionalField,
normalizeOptionalMultilineField,
@@ -19,7 +22,23 @@ import {
} from './agent-status-field-normalization'
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
-export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
+import {
+ normalizeAgentSubagentsField,
+ type AgentSubagentSnapshot
+} from './agent-status-subagent-snapshot'
+
+export {
+ AGENT_MODEL_MAX_LENGTH,
+ AGENT_STATUS_MAX_FIELD_LENGTH,
+ AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
+ AGENT_TYPE_MAX_LENGTH
+} from './agent-status-field-normalization'
+export {
+ AGENT_STATUS_MAX_SUBAGENTS,
+ agentSubagentsEqual,
+ type AgentSubagentSnapshot,
+ type AgentSubagentState
+} from './agent-status-subagent-snapshot'
export type {
AgentStatusCacheIdentity,
AgentStatusClearIpcPayload,
@@ -54,22 +73,6 @@ export type AgentStatusOrchestrationContext = {
attention?: OrchestrationFleetAttention
}
-export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' | 'unverifiable'
-
-/** A live in-process child of the pane's provider session. Rendered as an
- * indented child row with no PTY of its own. */
-export type AgentSubagentSnapshot = {
- /** Provider-assigned lifecycle id. */
- id: string
- agentType?: string
- /** Provider model used by this child, when exposed by its lifecycle event. */
- model?: string
- description?: string
- state: AgentSubagentState
- /** Timestamp (ms) when this subagent was first observed. */
- startedAt: number
-}
-
export type AgentStatusEntry = {
/** Renderer-local status-feed confirmation for children; absent on hook rows. */
subagentObservation?: 'live' | 'unverifiable'
@@ -241,8 +244,6 @@ export function pickParsedAgentStatusPayload(
*/
/** Maximum character length for the toolName field. */
export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60
-/** Maximum character length for the toolInput preview. */
-export const AGENT_STATUS_TOOL_INPUT_MAX_LENGTH = 160
/** Maximum character length for the lastAssistantMessage preview.
* Why: 8 KB fits a multi-paragraph summary while bounding per-pane cache against a buggy/malicious agent spamming huge strings. */
export const AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH = 8000
@@ -264,67 +265,11 @@ const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES)
export function isAgentStatusState(value: unknown): value is AgentStatusState {
return typeof value === 'string' && VALID_STATES.has(value)
}
-/** Maximum character length for the agentType label. Truncated on parse. */
-export const AGENT_TYPE_MAX_LENGTH = 40
-export const AGENT_MODEL_MAX_LENGTH = 120
-/** Maximum subagent child rows carried per status entry. Bounds per-pane cache
- * and IPC fanout against a runaway spawner. */
-export const AGENT_STATUS_MAX_SUBAGENTS = 32
export const AGENT_STATUS_JSON_STRUCTURE_LIMITS = {
structuralTokens: 4096,
nestingDepth: 16
} as const
-const AGENT_SUBAGENT_ID_MAX_LENGTH = 64
-
-function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null {
- if (typeof value !== 'object' || value === null) {
- return null
- }
- const obj = value as Record
- if (typeof obj.id !== 'string') {
- return null
- }
- const id = obj.id.trim()
- if (id.length === 0 || id.length > AGENT_SUBAGENT_ID_MAX_LENGTH) {
- return null
- }
- if (
- obj.state !== 'working' &&
- obj.state !== 'blocked' &&
- obj.state !== 'waiting' &&
- obj.state !== 'idle' &&
- obj.state !== 'unverifiable'
- ) {
- return null
- }
- return {
- id,
- state: obj.state,
- startedAt:
- typeof obj.startedAt === 'number' && Number.isFinite(obj.startedAt) ? obj.startedAt : 0,
- agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH),
- model: normalizeOptionalField(obj.model, AGENT_MODEL_MAX_LENGTH),
- description: normalizeOptionalField(obj.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH)
- }
-}
-
-function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | undefined {
- if (!Array.isArray(value) || value.length === 0) {
- return undefined
- }
- const normalized: AgentSubagentSnapshot[] = []
- for (const item of value) {
- const snapshot = normalizeSubagentSnapshot(item)
- if (snapshot) {
- normalized.push(snapshot)
- if (normalized.length >= AGENT_STATUS_MAX_SUBAGENTS) {
- break
- }
- }
- }
- return normalized.length > 0 ? normalized : undefined
-}
/** A malformed `mainAgent` drops the FIELD, never the row: the combined `state` is still valid
* evidence, and readers fall back to it exactly as they do for a host that predates the field. */
@@ -348,35 +293,6 @@ export function normalizeMainAgentStatusField(value: unknown): AgentMainAgentSta
}
}
-/** Structural equality for subagent lists so stores can reuse the previous
- * array reference (and skip fanout) when nothing actually changed. */
-export function agentSubagentsEqual(
- a: AgentSubagentSnapshot[] | undefined,
- b: AgentSubagentSnapshot[] | undefined
-): boolean {
- if (a === b) {
- return true
- }
- if (!a || !b || a.length !== b.length) {
- return !a && !b
- }
- for (let i = 0; i < a.length; i++) {
- const x = a[i]
- const y = b[i]
- if (
- x.id !== y.id ||
- x.state !== y.state ||
- x.startedAt !== y.startedAt ||
- x.agentType !== y.agentType ||
- x.model !== y.model ||
- x.description !== y.description
- ) {
- return false
- }
- }
- return true
-}
-
/**
* Normalize and validate an already-parsed agent status object. Shared by the
* JSON string entry point (`parseAgentStatusPayload`) and the object entry
@@ -424,7 +340,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload |
interrupted: obj.interrupted === true && state === 'done' ? true : undefined,
sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined,
turnCompletedAt: normalizeTurnCompletedAtField(obj.turnCompletedAt, state),
- subagents: normalizeSubagentsField(obj.subagents),
+ subagents: normalizeAgentSubagentsField(obj.subagents),
mainAgent: normalizeMainAgentStatusField(obj.mainAgent)
}
}
From ee954ce4371ac20e8da3cdc7dcd978965540d947 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 24 Sep 2026 13:04:59 -0700
Subject: [PATCH 08/11] refactor(sidebar): one legacy builder family, one
reader clock, frozen settled rows
- The chat strip's task-roster rows are built in the shared row model beside the
other two builders, with one placeholder set and the one detail rule. The
module header states when each legacy builder is deleted.
- A child's "No update" duration reads the parent's reader clock (receipt time
for a mirrored parent); a view child's own stamp is moved onto that clock.
The full sidebar row reads the same value as the compact row.
- The failed->blocked / interrupted->idle collapse has one owner, shared by the
sidebar row state and the strip header.
- A settled strip row shows its run frozen at settledAt instead of a growing
"ended N ago", so a strip of only finished work never wakes the 1 Hz tick.
---
.../agent-child-row-parity.test.tsx | 90 ++++++++++++-
.../src/components/agent-child-row-text.ts | 18 +--
.../dashboard/DashboardAgentRow.tsx | 18 ++-
.../NativeChatBackgroundTasksStatus.test.tsx | 44 ++++++
.../NativeChatBackgroundTasksStatus.tsx | 23 ++--
.../native-chat/background-task-roster.ts | 126 ++++++------------
.../sidebar/worktree-subagent-child-rows.ts | 23 +---
src/renderer/src/i18n/locales/en.json | 3 +-
src/renderer/src/i18n/locales/es.json | 3 +-
src/renderer/src/i18n/locales/fr.json | 3 +-
src/renderer/src/i18n/locales/ja.json | 3 +-
src/renderer/src/i18n/locales/ko.json | 3 +-
src/renderer/src/i18n/locales/zh.json | 3 +-
src/shared/agent-child-row-model.test.ts | 114 +++++++++++++++-
src/shared/agent-child-row-model.ts | 121 +++++++++++++++--
src/shared/agent-status-child-work-display.ts | 24 ++++
16 files changed, 457 insertions(+), 162 deletions(-)
diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx
index dec3a340fe53..8923da714ce5 100644
--- a/src/renderer/src/components/agent-child-row-parity.test.tsx
+++ b/src/renderer/src/components/agent-child-row-parity.test.tsx
@@ -5,6 +5,7 @@ import {
agentChildRowContextForParent,
type AgentChildRowContext
} from '../../../shared/agent-child-row-model'
+import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
@@ -132,13 +133,14 @@ function sidebarRows(parent: AgentStatusEntry, parentIsFresh = true): RenderedRo
}
function stripRows(
- children: AgentChildWorkView[],
- childRowContext?: AgentChildRowContext
+ children: AgentChildWorkView[] | undefined,
+ childRowContext?: AgentChildRowContext,
+ tasks: AgentSessionBackgroundTask[] = []
): RenderedRow[] {
const root = mount(
renderToStaticMarkup(
{
})
})
+describe('one child reads the same from every shape a host publishes', () => {
+ it('names and details it identically from views, the subagents snapshot and the task roster', () => {
+ // A placeholder description falls through to the child's real label on every path.
+ const children = [view('child', { description: 'task', agentType: 'Explore' })]
+ const snapshotParent: AgentStatusEntry = {
+ ...parentWith([]),
+ children: undefined,
+ subagents: [
+ {
+ id: 'child',
+ state: 'working',
+ startedAt: NOW - 5 * MINUTE,
+ description: 'task',
+ agentType: 'Explore'
+ }
+ ]
+ }
+ const roster: AgentSessionBackgroundTask[] = [
+ { id: 'child', kind: 'agent', description: 'task', name: 'Explore', state: 'working' }
+ ]
+ const expected: RenderedRow = { dot: 'Working', lead: 'Explore', trail: '' }
+ expect(sidebarRows(parentWith(children))).toEqual([expected])
+ expect(sidebarRows(snapshotParent)).toEqual([expected])
+ expect(stripRows(children)).toEqual([expected])
+ expect(stripRows(undefined, undefined, roster)).toEqual([expected])
+ })
+})
+
+describe('a mirrored parent and its children read one silence', () => {
+ const SKEW = 20 * MINUTE
+ // The host's clock runs 20 minutes ahead; this machine received its last word 3 minutes ago.
+ function mirroredParent(overrides: Partial): AgentStatusEntry {
+ return {
+ ...parentWith([]),
+ state: 'working',
+ updatedAt: NOW - 3 * MINUTE + SKEW,
+ mirroredEvidenceReceivedAt: NOW - 3 * MINUTE,
+ ...overrides
+ }
+ }
+
+ it('times a snapshot child on the receipt clock the parent decays on', () => {
+ const parent = mirroredParent({
+ children: undefined,
+ subagents: [
+ { id: 'child', state: 'working', startedAt: NOW - 9 * MINUTE, description: 'Audit' }
+ ]
+ })
+ expect(sidebarRows(parent, false)[0].trail).toBe('No update in 3m')
+ })
+
+ it('times a view child by its own host-clock age, never across machines', () => {
+ const children = [view('child', { observedAt: NOW - 5 * MINUTE + SKEW })]
+ const parent = mirroredParent({ children })
+ expect(sidebarRows(parent, false)[0].trail).toBe('No update in 5m')
+ expect(stripRows(children, agentChildRowContextForParent(parent, false))[0].trail).toBe(
+ 'No update in 5m'
+ )
+ const [agent] = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: false })
+ const full = mount(
+ renderToStaticMarkup(
+
+ {}}
+ onDismiss={() => {}}
+ stateDotSize="sm"
+ hideExpand
+ />
+
+ )
+ )
+ expect(full.textContent).toContain('No update in 5m')
+ })
+})
+
describe('a lost or stale parent reads the same on both surfaces', () => {
const children = [
view('child', {
@@ -408,7 +487,7 @@ describe('sibling child rows keep their own clocks', () => {
])
})
- it('times a settled child from when it ended', () => {
+ it('times a settled child from when it ended in the sidebar, and freezes its run in the strip', () => {
const [agent] = buildSubagentChildRows({
parentEntry: parentWith([settled('succeeded')]),
tab,
@@ -440,7 +519,8 @@ describe('sibling child rows keep their own clocks', () => {
/>
)
)
- expect(strip.querySelector('li')?.textContent).toContain('ended 3m 0s ago')
+ // Ran from 5m ago until it settled 3m ago; a finished row never ticks.
+ expect(strip.querySelector('li')?.textContent).toMatch(/2m 0s$/)
})
})
diff --git a/src/renderer/src/components/agent-child-row-text.ts b/src/renderer/src/components/agent-child-row-text.ts
index ea6d5e9df02a..96ef7d1d5c72 100644
--- a/src/renderer/src/components/agent-child-row-text.ts
+++ b/src/renderer/src/components/agent-child-row-text.ts
@@ -1,6 +1,5 @@
import type { AgentChildRowModel } from '../../../shared/agent-child-row-model'
import { formatAgentTypeLabel } from '../../../shared/agent-type-label'
-import { formatNativeChatDuration } from '../../../shared/native-chat-turn-status'
import { agentStateLabel } from '@/components/AgentStateDot'
import { backgroundTaskStateReason } from '@/components/native-chat/background-task-roster'
import { translate } from '@/i18n/i18n'
@@ -14,6 +13,11 @@ export type AgentChildRowText = {
trail: string
}
+/** How long this child has been silent, on the reader's clock the model measured it on. */
+export function agentChildRowNoUpdateLabel(row: AgentChildRowModel, now: number): string {
+ return agentNoUpdateLabel({ updatedAt: row.recencyAt }, now)
+}
+
/** The words for a row's detail, reusing the phrasing a CLI agent row uses for the same fact. */
export function agentChildRowDetailText(row: AgentChildRowModel, now: number): string {
const detail = row.detail
@@ -33,7 +37,7 @@ export function agentChildRowDetailText(row: AgentChildRowModel, now: number): s
case 'ended':
return translate('components.agentChildRow.ended', 'Ended')
case 'no-update':
- return agentNoUpdateLabel({ updatedAt: row.recencyAt }, now)
+ return agentChildRowNoUpdateLabel(row, now)
case 'role':
return formatAgentTypeLabel(detail.agentType)
case 'reason':
@@ -63,13 +67,3 @@ export function agentChildRowText(row: AgentChildRowModel, now: number): AgentCh
}
return { lead: name, trail: detail }
}
-
-/** "ended 3m ago" for a settled row whose host reported when; null otherwise. */
-export function agentChildRowEndedLabel(row: AgentChildRowModel, now: number): string | null {
- if (!row.settled || row.settledAt === undefined) {
- return null
- }
- return translate('components.agentChildRow.endedAgo', 'ended {{value0}} ago', {
- value0: formatNativeChatDuration((now - row.settledAt) / 1000)
- })
-}
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
index 1d1208718ec9..426fe8b57aac 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
@@ -15,7 +15,10 @@ import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardD
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
import { useAgentRowConversationName } from './use-agent-row-conversation-name'
import { lastEnteredDoneAt } from './agent-finished-timestamp'
-import { agentChildRowMessageLine } from '@/components/agent-child-row-text'
+import {
+ agentChildRowMessageLine,
+ agentChildRowNoUpdateLabel
+} from '@/components/agent-child-row-text'
function formatTimeAgo(ts: number, now: number): string {
const delta = now - ts
@@ -25,6 +28,13 @@ function formatTimeAgo(ts: number, now: number): string {
return `${formatCompactDuration(delta)} ago`
}
+// A child row's silence is the model's, on the clock its compact row and the strip read.
+function rowNoUpdateLabel(agent: DashboardAgentRowData, now: number): string {
+ return agent.childRow
+ ? agentChildRowNoUpdateLabel(agent.childRow, now)
+ : agentNoUpdateLabel(agent.entry, now)
+}
+
function stateDotTooltipLabel(
agent: DashboardAgentRowData,
dotState: AgentDotState,
@@ -35,9 +45,7 @@ function stateDotTooltipLabel(
}
// Why: report the observation, not a verdict on the agent — the elapsed gap is what
// lets the user apply context Orca has no way to know (a long build, a slow download).
- return dotState === 'unverifiable'
- ? agentNoUpdateLabel(agent.entry, now)
- : agentStateLabel(dotState)
+ return dotState === 'unverifiable' ? rowNoUpdateLabel(agent, now) : agentStateLabel(dotState)
}
type Props = {
@@ -163,7 +171,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now)
// Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the
// row's own timestamp slot rather than hiding in a hover tooltip.
- const noUpdateLabel = dotState === 'unverifiable' ? agentNoUpdateLabel(agent.entry, now) : null
+ const noUpdateLabel = dotState === 'unverifiable' ? rowNoUpdateLabel(agent, now) : null
// Why: always show the chevron so the row's right edge doesn't flicker as content grows/shrinks.
diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx
index 365be31cc476..e37bfa0f9dad 100644
--- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx
@@ -335,3 +335,47 @@ it('stops elapsed renders in a hidden pane and catches up on reveal', () => {
unmount()
expect(vi.getTimerCount()).toBe(0)
})
+
+it('lets the 1 Hz tick sleep while every row has settled, with each run frozen', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000_000)
+ const committed = vi.fn()
+ const finished = (id: string, firstObservedAt: number, settledAt: number) => ({
+ id,
+ providerId: `task-${id}`,
+ kind: 'agent' as const,
+ description: `child ${id}`,
+ state: 'done' as const,
+ membership: 'settled' as const,
+ outcome: 'succeeded' as const,
+ firstObservedAt,
+ observedAt: settledAt,
+ settledAt,
+ stoppable: false,
+ invocation: { invocationId: `spawn-${id}`, generation: 1 }
+ })
+ render(
+
+ {}}
+ isVisible
+ tasks={[]}
+ settledTasks={[]}
+ childViews={[finished('a', 400_000, 520_000), finished('b', 700_000, 760_000)]}
+ indicatorActive
+ supportsTaskStop
+ supportsStopAll
+ stoppingTaskIds={new Set()}
+ stoppingAll={false}
+ onStop={() => {}}
+ />
+
+ )
+ const rows = screen.getAllByRole('listitem').map((row) => row.textContent)
+ expect(rows).toEqual(['child a · Agent2m 0s', 'child b · Agent1m 0s'])
+ committed.mockClear()
+ act(() => vi.advanceTimersByTime(5_000))
+ expect(committed).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
index 39b75a0f3686..8fe396346cdd 100644
--- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx
@@ -7,14 +7,15 @@ import type {
} from '../../../../shared/agent-child-row-model'
import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
import { AgentChildRowContent } from '@/components/AgentChildRowContent'
-import { agentChildRowEndedLabel, agentChildRowName } from '@/components/agent-child-row-text'
+import { agentChildRowName } from '@/components/agent-child-row-text'
import { Button } from '@/components/ui/button'
import { useNow } from '@/hooks/use-now'
import { translate } from '@/i18n/i18n'
import { backgroundTasksHeaderContent } from './background-task-header-content'
import {
- backgroundTaskElapsedLabel,
backgroundTaskGroupLabel,
+ backgroundTaskRowElapsedLabel,
+ backgroundTaskRowTicks,
buildBackgroundTaskGroups,
buildBackgroundTaskGroupsFromViews,
formatBackgroundTaskTokens,
@@ -70,11 +71,6 @@ function kindIconTone(kind: AgentSessionBackgroundTask['kind'], dimmed: boolean)
return dimmed ? `${tone}/40` : tone
}
-/** A still-growing clock on finished work would lie: settled rows say when they ended instead. */
-function rowHasClock(row: AgentChildRowModel): boolean {
- return row.settled ? row.settledAt !== undefined : row.firstObservedAt > 0
-}
-
function BackgroundTaskRow(props: {
row: AgentChildRowModel
now: number
@@ -86,9 +82,7 @@ function BackgroundTaskRow(props: {
const Icon = KIND_ICONS[row.kind]
const meta = [
row.totalTokens !== undefined ? formatBackgroundTaskTokens(row.totalTokens) : null,
- row.settled
- ? agentChildRowEndedLabel(row, now)
- : backgroundTaskElapsedLabel(row.firstObservedAt, now)
+ backgroundTaskRowElapsedLabel(row, now)
]
.filter((part): part is string => part !== null)
.join(' · ')
@@ -154,8 +148,8 @@ function BackgroundTaskRow(props: {
)
}
-function groupsHaveClock(rows: readonly AgentChildRowModel[]): boolean {
- return rows.some((row) => rowHasClock(row) || groupsHaveClock(row.owned))
+function rowsTick(rows: readonly AgentChildRowModel[]): boolean {
+ return rows.some((row) => backgroundTaskRowTicks(row) || rowsTick(row.owned))
}
export function NativeChatBackgroundTasksStatus(props: {
@@ -199,8 +193,9 @@ export function NativeChatBackgroundTasksStatus(props: {
)
const singleLiveCommand =
groups.length === 1 && groups[0].kind === 'command' && groups[0].tasks.length === 1
- const hasElapsed = groups.some((group) => groupsHaveClock(group.tasks.map((entry) => entry.row)))
- const now = useNow(1_000, props.isVisible && hasElapsed && (expanded || singleLiveCommand))
+ // Settled rows are frozen, so a strip of only finished work never wakes the 1 Hz tick.
+ const ticks = groups.some((group) => rowsTick(group.tasks.map((entry) => entry.row)))
+ const now = useNow(1_000, props.isVisible && ticks && (expanded || singleLiveCommand))
const header = backgroundTasksHeaderContent(groups, { narrow, now })
const headerText = `${header.segments.map((segment) => segment.text).join(' · ')}${header.detail ? `${header.segments.length > 0 ? ' — ' : ''}${header.detail}` : ''}`
return (
diff --git a/src/renderer/src/components/native-chat/background-task-roster.ts b/src/renderer/src/components/native-chat/background-task-roster.ts
index a65147f7b750..7b62bb1f57c8 100644
--- a/src/renderer/src/components/native-chat/background-task-roster.ts
+++ b/src/renderer/src/components/native-chat/background-task-roster.ts
@@ -8,10 +8,12 @@ import type {
} from '../../../../shared/agent-session-wire'
import {
buildAgentChildRowModels,
+ buildLegacyTaskRowModels,
+ usableAgentChildLabel,
type AgentChildRowContext,
type AgentChildRowModel
} from '../../../../shared/agent-child-row-model'
-import type { AgentChildDisplayState } from '../../../../shared/agent-status-child-work-display'
+import { agentChildRunStateFor } from '../../../../shared/agent-status-child-work-display'
import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
import { formatNativeChatDuration } from '../../../../shared/native-chat-turn-status'
import { translate } from '@/i18n/i18n'
@@ -31,17 +33,6 @@ export type BackgroundTaskGroup = { kind: TaskKind; tasks: BackgroundRosterTask[
/** Fixed presentation order; groups render only when non-empty. */
const KIND_ORDER: readonly TaskKind[] = ['agent', 'command', 'monitor', 'workflow', 'unknown']
-/** Provider strings that carry no identity; a row falls through to its kind label. */
-const PLACEHOLDER_NAMES = new Set(['unknown', 'untitled', 'task', 'subagent'])
-
-function usableTaskText(value: string | undefined): string | null {
- const trimmed = value?.trim()
- if (!trimmed || PLACEHOLDER_NAMES.has(trimmed.toLowerCase())) {
- return null
- }
- return trimmed
-}
-
export function backgroundTaskKindLabel(kind: TaskKind): string {
switch (kind) {
case 'agent':
@@ -57,50 +48,16 @@ export function backgroundTaskKindLabel(kind: TaskKind): string {
}
}
-/** Display name: description → name → kind label. Empty-after-trim and
- * placeholder values fall through, so a row always renders something. */
+/** The transcript row's name: description → name → kind label. Empty-after-trim and
+ * placeholder values fall through, so the row always renders something. */
export function resolveBackgroundTaskName(task: AgentSessionBackgroundTask): string {
return (
- usableTaskText(task.description) ??
- usableTaskText(task.name) ??
+ usableAgentChildLabel(task.description) ??
+ usableAgentChildLabel(task.name) ??
backgroundTaskKindLabel(task.kind)
)
}
-function effectiveState(task: AgentSessionBackgroundTask, settled: boolean): RunState {
- if (task.state) {
- return task.state
- }
- if (settled) {
- return 'done'
- }
- return task.kind === 'monitor' ? 'monitoring' : 'working'
-}
-
-/** A host that publishes only the task roster decided each row's state; the row keeps it. */
-function legacyTaskRow(task: AgentSessionBackgroundTask, settled: boolean): AgentChildRowModel {
- const state = effectiveState(task, settled)
- const startedAt = task.startedAt ?? 0
- return {
- id: task.id,
- providerId: task.id,
- kind: task.kind,
- displayState: state,
- name: resolveBackgroundTaskName(task),
- detail:
- state === 'waiting' || state === 'blocked' || state === 'unverifiable'
- ? { kind: 'reason', state }
- : null,
- firstObservedAt: startedAt,
- recencyAt: startedAt,
- ...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}),
- // Absent means stoppable: a host predating the field published only rows its stop could act on.
- canStop: task.stoppable !== false,
- settled,
- owned: []
- }
-}
-
/** Stable-sort first-seen then id, so a live update never reshuffles surviving rows. */
function groupRosterEntries(entries: BackgroundRosterTask[]): BackgroundTaskGroup[] {
entries.sort((left, right) => {
@@ -113,48 +70,31 @@ function groupRosterEntries(entries: BackgroundRosterTask[]): BackgroundTaskGrou
})).filter((group) => group.tasks.length > 0)
}
-/** Merge live and settled tasks into kind groups. */
+function rosterEntries(rows: readonly AgentChildRowModel[]): BackgroundTaskGroup[] {
+ return groupRosterEntries(
+ rows.map((row) => ({ row, state: agentChildRunStateFor(row.displayState) }))
+ )
+}
+
+/** Kind groups from a host that publishes only the task roster, live and settled merged. */
export function buildBackgroundTaskGroups(
tasks: readonly AgentSessionBackgroundTask[],
settledTasks: readonly AgentSessionBackgroundTask[]
): BackgroundTaskGroup[] {
- // Older hosts can retain a previous turn beside its resumed live task.
- const owners = new Map()
- for (const [roster, settled] of [
- [settledTasks, true],
- [tasks, false]
- ] as const) {
- for (const task of roster) {
- const row = legacyTaskRow(task, settled)
- owners.set(task.id, { row, state: headerRunState(row.displayState) })
- }
- }
- return groupRosterEntries([...owners.values()])
+ // An old host's unlabeled row keeps the kind label it always showed; a view row reads its state.
+ return rosterEntries(
+ buildLegacyTaskRowModels(tasks, settledTasks).map((row) =>
+ row.name ? row : { ...row, name: backgroundTaskKindLabel(row.kind) }
+ )
+ )
}
// Until a caller passes the session's parent-row context, every live claim stands as reported.
const REPORTED_ROW_CONTEXT: AgentChildRowContext = {
parentEvidenceFresh: true,
transportObservation: 'live',
- parentObservedAt: 0
-}
-
-/** The header's word for a row: a failure reads as the host's `blocked`, a cancel as `idle`. */
-function headerRunState(displayState: AgentChildDisplayState): RunState {
- switch (displayState) {
- case 'failed':
- return 'blocked'
- case 'interrupted':
- return 'idle'
- case 'working':
- case 'monitoring':
- case 'waiting':
- case 'blocked':
- case 'done':
- case 'idle':
- case 'unverifiable':
- return displayState
- }
+ parentObservedAt: 0,
+ hostClockOffsetMs: 0
}
/** Kind groups from the host's child views: the main agent's work at the top, each child's own
@@ -164,12 +104,7 @@ export function buildBackgroundTaskGroupsFromViews(
views: readonly AgentChildWorkView[],
context: AgentChildRowContext = REPORTED_ROW_CONTEXT
): BackgroundTaskGroup[] {
- return groupRosterEntries(
- buildAgentChildRowModels(views, context).map((row) => ({
- row,
- state: headerRunState(row.displayState)
- }))
- )
+ return rosterEntries(buildAgentChildRowModels(views, context))
}
export function backgroundTaskStateWord(state: RunState): string {
@@ -232,6 +167,21 @@ export function backgroundTaskElapsedLabel(startedAt: number, now: number): stri
return formatNativeChatDuration((now - startedAt) / 1000)
}
+/** Whether a row's elapsed time moves: only live work's does. */
+export function backgroundTaskRowTicks(row: AgentChildRowModel): boolean {
+ return !row.settled && row.firstObservedAt > 0
+}
+
+/** A live row's clock runs; a settled row's stops where it settled, so finished work never ticks. */
+export function backgroundTaskRowElapsedLabel(row: AgentChildRowModel, now: number): string | null {
+ if (!row.settled) {
+ return backgroundTaskElapsedLabel(row.firstObservedAt, now)
+ }
+ return row.settledAt !== undefined
+ ? backgroundTaskElapsedLabel(row.firstObservedAt, row.settledAt)
+ : null
+}
+
export function backgroundTaskGroupLabel(kind: TaskKind): string {
switch (kind) {
case 'agent':
diff --git a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
index 61533ebc6257..729038d46ef2 100644
--- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
+++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
@@ -8,7 +8,10 @@ import {
flattenAgentChildRowModels,
type AgentChildRowModel
} from '../../../../shared/agent-child-row-model'
-import type { AgentChildDisplayState } from '../../../../shared/agent-status-child-work-display'
+import {
+ agentChildRunStateFor,
+ type AgentChildDisplayState
+} from '../../../../shared/agent-status-child-work-display'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
/** Row-identity key for an in-process subagent child row. The NUL separator
@@ -21,21 +24,9 @@ function subagentRowKey(parentPaneKey: string, subagentId: string): string {
/** The lifecycle word every reader of a CLI row already understands; the row's own dot says more. */
function agentRowStateFor(displayState: AgentChildDisplayState): AgentRowState {
- switch (displayState) {
- case 'working':
- case 'monitoring':
- return 'working'
- case 'failed':
- return 'blocked'
- case 'interrupted':
- return 'idle'
- case 'waiting':
- case 'blocked':
- case 'done':
- case 'idle':
- case 'unverifiable':
- return displayState
- }
+ const runState = agentChildRunStateFor(displayState)
+ // A CLI row carries monitoring as `working` plus its `workingMode`.
+ return runState === 'monitoring' ? 'working' : runState
}
function childDashboardRow(
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 0d54c5c5a2a2..6c9e50f26a18 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18198,8 +18198,7 @@
}
},
"agentChildRow": {
- "ended": "Ended",
- "endedAgo": "ended {{value0}} ago"
+ "ended": "Ended"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 9b42747499c9..599ca708bfec 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -15170,8 +15170,7 @@
}
},
"agentChildRow": {
- "ended": "Finalizado",
- "endedAgo": "finalizó hace {{value0}}"
+ "ended": "Finalizado"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index ac1b5f51c80a..41713b44aaf2 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -18170,8 +18170,7 @@
}
},
"agentChildRow": {
- "ended": "Terminé",
- "endedAgo": "terminé il y a {{value0}}"
+ "ended": "Terminé"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index d3cade6b831b..965e9868797e 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -18071,8 +18071,7 @@
}
},
"agentChildRow": {
- "ended": "終了",
- "endedAgo": "{{value0}}前に終了"
+ "ended": "終了"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 8e76f570bf6f..e81a9c91a046 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -18071,8 +18071,7 @@
}
},
"agentChildRow": {
- "ended": "종료됨",
- "endedAgo": "{{value0}} 전에 종료됨"
+ "ended": "종료됨"
}
},
"dashboardPopout": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 0b2da20f4271..f0fd54b665d1 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -18071,8 +18071,7 @@
}
},
"agentChildRow": {
- "ended": "已结束",
- "endedAgo": "{{value0}}前结束"
+ "ended": "已结束"
}
},
"dashboardPopout": {
diff --git a/src/shared/agent-child-row-model.test.ts b/src/shared/agent-child-row-model.test.ts
index 125deb5ac29e..a99ef0a71520 100644
--- a/src/shared/agent-child-row-model.test.ts
+++ b/src/shared/agent-child-row-model.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
+ agentChildRowContextForParent,
buildAgentChildRowModels,
buildLegacyAgentChildRowModels,
+ buildLegacyTaskRowModels,
flattenAgentChildRowModels,
type AgentChildRowContext,
type AgentChildRowModel
@@ -11,7 +13,8 @@ import type { AgentChildWorkView } from './agent-status-child-work-view'
const FRESH: AgentChildRowContext = {
parentEvidenceFresh: true,
transportObservation: 'live',
- parentObservedAt: 900
+ parentObservedAt: 900,
+ hostClockOffsetMs: 0
}
const STALE: AgentChildRowContext = { ...FRESH, parentEvidenceFresh: false }
const LOST: AgentChildRowContext = { ...FRESH, transportObservation: 'unverifiable' }
@@ -270,3 +273,112 @@ describe('buildLegacyAgentChildRowModels', () => {
expect(rows.map((row) => row.displayState)).toEqual(['unverifiable', 'unverifiable', 'idle'])
})
})
+
+describe('buildLegacyTaskRowModels', () => {
+ it('keeps the state the host decided and says only its reason', () => {
+ const rows = buildLegacyTaskRowModels(
+ [
+ { id: 'w', kind: 'agent', description: 'Review', state: 'working', startedAt: 10 },
+ { id: 'q', kind: 'agent', description: 'Approve', state: 'waiting' },
+ { id: 'u', kind: 'agent', description: 'Lost', state: 'unverifiable' },
+ { id: 'm', kind: 'monitor', description: 'tail -f log' },
+ { id: 'c', kind: 'command', description: 'npm test', stoppable: false }
+ ],
+ [{ id: 'd', kind: 'agent', description: 'Done', totalTokens: 900 }]
+ )
+ expect(rows.map((row) => [row.id, row.displayState, row.detail])).toEqual([
+ ['d', 'done', null],
+ ['w', 'working', null],
+ ['q', 'waiting', { kind: 'reason', state: 'waiting' }],
+ ['u', 'unverifiable', { kind: 'reason', state: 'unverifiable' }],
+ ['m', 'monitoring', null],
+ ['c', 'working', null]
+ ])
+ expect(rows.map((row) => row.canStop)).toEqual([true, true, true, true, true, false])
+ expect(rows[0]).toMatchObject({ settled: true, totalTokens: 900 })
+ })
+
+ it('lets a resumed live task replace its retained settled row', () => {
+ const rows = buildLegacyTaskRowModels(
+ [{ id: 'r', kind: 'agent', state: 'working' }],
+ [{ id: 'r', kind: 'agent', state: 'done' }]
+ )
+ expect(rows).toHaveLength(1)
+ expect(rows[0]).toMatchObject({ settled: false, displayState: 'working' })
+ })
+})
+
+describe('one label rule for every shape a host publishes', () => {
+ it.each(['task', ' Subagent ', 'unknown', ' '])(
+ 'skips the placeholder %j on all three',
+ (label) => {
+ const [fromView] = buildAgentChildRowModels(
+ [view('a', { description: label, agentType: 'Explore' })],
+ FRESH
+ )
+ const [fromSnapshot] = buildLegacyAgentChildRowModels(
+ [{ id: 'a', state: 'working', startedAt: 10, description: label, agentType: 'Explore' }],
+ FRESH
+ )
+ const [fromRoster] = buildLegacyTaskRowModels(
+ [{ id: 'a', kind: 'agent', state: 'working', description: label, name: 'Explore' }],
+ []
+ )
+ expect([fromView.name, fromSnapshot.name, fromRoster.name]).toEqual([
+ 'Explore',
+ 'Explore',
+ 'Explore'
+ ])
+ }
+ )
+
+ it('leaves a row with no usable label unnamed on all three', () => {
+ const [fromSnapshot] = buildLegacyAgentChildRowModels(
+ [{ id: 'a', state: 'working', startedAt: 10, description: 'task' }],
+ FRESH
+ )
+ const [fromRoster] = buildLegacyTaskRowModels(
+ [{ id: 'a', kind: 'agent', state: 'working', description: 'task' }],
+ []
+ )
+ expect([only([view('a', { description: 'task', agentType: undefined })]).name]).toEqual([''])
+ expect([fromSnapshot.name, fromRoster.name]).toEqual(['', ''])
+ })
+})
+
+describe('agentChildRowContextForParent: the reader clock', () => {
+ const MIRRORED_PARENT = {
+ // The host's clock runs 20 minutes ahead of this machine's.
+ updatedAt: 1_200_000 + 20 * 60_000,
+ evidenceObservedAt: 1_190_000 + 20 * 60_000,
+ mirroredEvidenceReceivedAt: 1_190_000
+ }
+
+ it("measures a mirrored parent on this machine's receipt clock, never the host's", () => {
+ const context = agentChildRowContextForParent(MIRRORED_PARENT, false)
+ expect(context).toMatchObject({ parentObservedAt: 1_190_000, hostClockOffsetMs: -20 * 60_000 })
+ const [legacy] = buildLegacyAgentChildRowModels(
+ [{ id: 'a', state: 'working', startedAt: 10 }],
+ context
+ )
+ expect(legacy.recencyAt).toBe(1_190_000)
+ // A view's own host stamp moves onto the reader clock; its host-clock age is kept.
+ const [child] = buildAgentChildRowModels(
+ [view('a', { observedAt: 1_100_000 + 20 * 60_000 })],
+ context
+ )
+ expect(child.recencyAt).toBe(1_100_000)
+ expect(child.observedAt).toBe(1_100_000 + 20 * 60_000)
+ })
+
+ it('leaves a parent observed on this machine on its own clock', () => {
+ expect(
+ agentChildRowContextForParent({ updatedAt: 500, evidenceObservedAt: 400 }, true)
+ ).toEqual({
+ parentEvidenceFresh: true,
+ transportObservation: 'live',
+ parentObservedAt: 400,
+ hostClockOffsetMs: 0
+ })
+ })
+})
diff --git a/src/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts
index 281dcbcd7915..8e1cf606eea1 100644
--- a/src/shared/agent-child-row-model.ts
+++ b/src/shared/agent-child-row-model.ts
@@ -1,3 +1,17 @@
+/**
+ * One row model for every surface that lists child work (the sidebar's child rows and the chat
+ * strip), with one builder per shape a host may publish. All three name a row with one label rule
+ * (`usableAgentChildLabel`) and decide what it says with one detail rule (`agentChildRowDetail`):
+ * - `buildAgentChildRowModels`: the host's child views (`children`), the source of record.
+ * - `buildLegacyAgentChildRowModels`: the `subagents` snapshot.
+ * - `buildLegacyTaskRowModels`: the session feed's `tasks` / `settledTasks` roster.
+ *
+ * The two legacy builders exist only for hosts that publish no views, and each is deleted, not
+ * migrated, when its last publisher is gone: the snapshot builder once CLI panes publish
+ * `children` and no supported paired host predates that; the roster builder once no supported
+ * paired host predates child views on the session feed.
+ */
+import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire'
import type { AgentChildWorkKind } from './agent-status-child-work'
import { resolveAgentChildWorkFreshness } from './agent-status-child-work-freshness'
import {
@@ -6,6 +20,10 @@ import {
type AgentChildDisplayState
} from './agent-status-child-work-display'
import type { AgentChildWorkView } from './agent-status-child-work-view'
+import {
+ agentStatusAuthorityObservedAt,
+ agentStatusEvidenceObservedAt
+} from './agent-status-freshness'
import type { AgentStatusEntry, AgentSubagentSnapshot } from './agent-status-types'
/** What a child row says beside its name. Surfaces format it; they never decide it. */
@@ -41,7 +59,8 @@ export type AgentChildRowModel = {
firstObservedAt: number
/** Last evidence for THIS child. Absent from a host that reports none. */
observedAt?: number
- /** The clock a "no update" reading measures: the child's own, else its parent's. */
+ /** The reader-clock time a "no update" reading measures from: the child's own evidence, else
+ * its parent's. */
recencyAt: number
settledAt?: number
totalTokens?: number
@@ -55,27 +74,38 @@ export type AgentChildRowContext = {
/** A stale parent makes every live claim beneath it unverifiable. */
parentEvidenceFresh: boolean
transportObservation: 'live' | 'unverifiable'
- /** The parent's evidence clock: recency for a child whose host reports no clock of its own. */
+ /** The parent's last evidence on the READER's clock (its receipt time when mirrored from another
+ * host): recency for a child whose host reports no clock of its own. */
parentObservedAt: number
+ /** Reader clock minus the host's, at that evidence; 0 for a parent observed on this machine. Moves
+ * a child's host-stamped clock onto the reader's, so no age subtracts across two machines. */
+ hostClockOffsetMs: number
}
/** The context a parent row gives its children. Every surface that lists one parent's children
* builds it here, from the same parent row, so a lost child reads the same everywhere. */
export function agentChildRowContextForParent(
- parent: Pick,
+ parent: Pick<
+ AgentStatusEntry,
+ 'updatedAt' | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'subagentObservation'
+ >,
parentEvidenceFresh: boolean
): AgentChildRowContext {
+ // The parent row decays on this same clock, so a child's silence reads the same as its parent's.
+ const parentObservedAt = agentStatusEvidenceObservedAt(parent)
return {
parentEvidenceFresh,
transportObservation: parent.subagentObservation ?? 'live',
- parentObservedAt: parent.updatedAt
+ parentObservedAt,
+ hostClockOffsetMs: parentObservedAt - agentStatusAuthorityObservedAt(parent)
}
}
/** Provider strings that carry no identity; the next label wins. */
const PLACEHOLDER_LABELS = new Set(['unknown', 'untitled', 'task', 'subagent'])
-function usableLabel(value: string | undefined): string | undefined {
+/** A provider label that names the child, or undefined for an empty or placeholder one. */
+export function usableAgentChildLabel(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed && !PLACEHOLDER_LABELS.has(trimmed.toLowerCase()) ? trimmed : undefined
}
@@ -102,6 +132,8 @@ function withFreshness(
}
type AgentChildRowDetailSource = {
+ /** `run-state`: the host reported only the child's run state (the legacy task roster). */
+ evidence: 'child' | 'run-state'
kind: AgentChildWorkKind
name: string
agentType?: string
@@ -129,6 +161,13 @@ function agentChildRowDetail(
source: AgentChildRowDetailSource,
displayState: AgentChildDisplayState
): AgentChildRowDetail | null {
+ if (source.evidence === 'run-state') {
+ return displayState === 'waiting' ||
+ displayState === 'blocked' ||
+ displayState === 'unverifiable'
+ ? { kind: 'reason', state: displayState }
+ : null
+ }
switch (displayState) {
case 'unverifiable':
return { kind: 'no-update' }
@@ -157,7 +196,10 @@ function agentChildRowDetail(
function viewName(view: AgentChildWorkView): string {
return (
- usableLabel(view.description) ?? usableLabel(view.name) ?? usableLabel(view.agentType) ?? ''
+ usableAgentChildLabel(view.description) ??
+ usableAgentChildLabel(view.name) ??
+ usableAgentChildLabel(view.agentType) ??
+ ''
)
}
@@ -185,6 +227,7 @@ function rowFromView(
...(view.model !== undefined ? { model: view.model } : {}),
detail: agentChildRowDetail(
{
+ evidence: 'child',
kind: view.kind,
name,
agentType: view.agentType,
@@ -196,7 +239,7 @@ function rowFromView(
),
firstObservedAt: view.firstObservedAt,
observedAt: view.observedAt,
- recencyAt: view.observedAt,
+ recencyAt: view.observedAt + context.hostClockOffsetMs,
...(view.settledAt !== undefined ? { settledAt: view.settledAt } : {}),
...(view.totalTokens !== undefined ? { totalTokens: view.totalTokens } : {}),
canStop: !settled && view.stoppable && view.providerId !== undefined,
@@ -243,7 +286,8 @@ export function buildLegacyAgentChildRowModels(
deriveAgentChildDisplayState({ state: subagent.state, membership: 'live' }, null),
context
)
- const name = subagent.description ?? subagent.agentType ?? ''
+ const name =
+ usableAgentChildLabel(subagent.description) ?? usableAgentChildLabel(subagent.agentType) ?? ''
return {
id: subagent.id,
providerId: subagent.id,
@@ -253,7 +297,7 @@ export function buildLegacyAgentChildRowModels(
...(subagent.agentType !== undefined ? { agentType: subagent.agentType } : {}),
...(subagent.model !== undefined ? { model: subagent.model } : {}),
detail: agentChildRowDetail(
- { kind: 'agent', name, agentType: subagent.agentType, settled: false },
+ { evidence: 'child', kind: 'agent', name, agentType: subagent.agentType, settled: false },
displayState
),
firstObservedAt: subagent.startedAt,
@@ -265,6 +309,65 @@ export function buildLegacyAgentChildRowModels(
})
}
+function legacyTaskDisplayState(
+ task: AgentSessionBackgroundTask,
+ settled: boolean
+): AgentChildDisplayState {
+ if (task.state) {
+ return task.state
+ }
+ if (settled) {
+ return 'done'
+ }
+ return task.kind === 'monitor' ? 'monitoring' : 'working'
+}
+
+// The host decided each row's state; the row keeps it.
+function legacyTaskRow(task: AgentSessionBackgroundTask, settled: boolean): AgentChildRowModel {
+ const displayState = legacyTaskDisplayState(task, settled)
+ const name = usableAgentChildLabel(task.description) ?? usableAgentChildLabel(task.name) ?? ''
+ const startedAt = task.startedAt ?? 0
+ return {
+ id: task.id,
+ providerId: task.id,
+ kind: task.kind,
+ displayState,
+ name,
+ detail: agentChildRowDetail(
+ { evidence: 'run-state', kind: task.kind, name, settled },
+ displayState
+ ),
+ firstObservedAt: startedAt,
+ recencyAt: startedAt,
+ ...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}),
+ // Absent means stoppable: a host predating the field published only rows its stop could act on.
+ canStop: task.stoppable !== false,
+ settled,
+ owned: []
+ }
+}
+
+/**
+ * Rows from a host that publishes only the session feed's task roster (a host predating child
+ * views): flat, each with the run state the host decided and no clock beyond its start.
+ */
+export function buildLegacyTaskRowModels(
+ tasks: readonly AgentSessionBackgroundTask[],
+ settledTasks: readonly AgentSessionBackgroundTask[]
+): AgentChildRowModel[] {
+ // Older hosts can retain a previous turn beside its resumed live task; the live row wins.
+ const rows = new Map()
+ for (const [roster, settled] of [
+ [settledTasks, true],
+ [tasks, false]
+ ] as const) {
+ for (const task of roster) {
+ rows.set(task.id, legacyTaskRow(task, settled))
+ }
+ }
+ return [...rows.values()]
+}
+
/** Every row of a tree, owners before what they own. */
export function flattenAgentChildRowModels(
rows: readonly AgentChildRowModel[]
diff --git a/src/shared/agent-status-child-work-display.ts b/src/shared/agent-status-child-work-display.ts
index 9dd75de4c736..c83957199f99 100644
--- a/src/shared/agent-status-child-work-display.ts
+++ b/src/shared/agent-status-child-work-display.ts
@@ -1,4 +1,5 @@
import { foldAgentLeadStatus } from './agent-lead-status-fold'
+import type { AgentSessionBackgroundTaskRunState } from './agent-session-background-task-wire'
import type { AgentChildWorkId, AgentChildWorkOutcome } from './agent-status-child-work'
import {
agentChildWorkLiveness,
@@ -88,3 +89,26 @@ export function deriveAgentChildDisplayState(
}
return view.membership === 'live' ? 'idle' : SETTLED_DISPLAY_STATE[view.outcome ?? 'unknown']
}
+
+/**
+ * A display state in the run-state words a host publishes and every older reader already speaks:
+ * a failure reads as `blocked`, a cancel as `idle`. The row's own dot keeps the real outcome.
+ */
+export function agentChildRunStateFor(
+ displayState: AgentChildDisplayState
+): AgentSessionBackgroundTaskRunState {
+ switch (displayState) {
+ case 'failed':
+ return 'blocked'
+ case 'interrupted':
+ return 'idle'
+ case 'working':
+ case 'monitoring':
+ case 'waiting':
+ case 'blocked':
+ case 'done':
+ case 'idle':
+ case 'unverifiable':
+ return displayState
+ }
+}
From 1232778720a885fdba8ce22297eb071573e4f19f Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 24 Sep 2026 13:20:25 -0700
Subject: [PATCH 09/11] test(sidebar): the sidebar row state and the strip
header share one lifecycle word
---
.../agent-child-row-parity.test.tsx | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx
index 8923da714ce5..4c7c186eeb69 100644
--- a/src/renderer/src/components/agent-child-row-parity.test.tsx
+++ b/src/renderer/src/components/agent-child-row-parity.test.tsx
@@ -11,6 +11,7 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow'
import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus'
+import { buildBackgroundTaskGroupsFromViews } from '@/components/native-chat/background-task-roster'
import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agent-row'
import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -571,3 +572,24 @@ describe('the chat strip from views', () => {
expect(stopShell?.hasAttribute('disabled')).toBe(true)
})
})
+
+describe('one lifecycle word for a child, on the sidebar row and the strip header', () => {
+ it.each<[string, AgentChildWorkView[], string, string]>([
+ ['failed', [settled('failed')], 'blocked', 'blocked'],
+ ['cancelled', [settled('cancelled')], 'idle', 'idle'],
+ ['ended, outcome unknown', [settled('unknown')], 'idle', 'idle'],
+ ['finished', [settled('succeeded')], 'done', 'done'],
+ ['monitoring its own shell', [settled('succeeded'), OWNED_SHELL], 'working', 'monitoring'],
+ ['waiting', [view('child', { state: 'waiting' })], 'waiting', 'waiting']
+ ])('%s', (_name, children, sidebarState, headerState) => {
+ const [sidebar] = buildSubagentChildRows({
+ parentEntry: parentWith(children),
+ tab,
+ parentIsFresh: true
+ })
+ const [group] = buildBackgroundTaskGroupsFromViews(children)
+ // A CLI row carries monitoring as `working` plus its working mode; every other word is shared.
+ expect(sidebar.state).toBe(sidebarState)
+ expect(group.tasks[0].state).toBe(headerState)
+ })
+})
From 57f622d263ec535ecba500c28c095e3167238cab Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:18:35 -0700
Subject: [PATCH 10/11] test(sidebar): a child running a shell in its turn
shows its Bash line with the shell nested beneath it
While a child's turn runs its shell, the host records the shell both as the
child's operation and as a live command the child owns. The strip shows the
child working its Bash line with the shell row nested beneath it, the header
counts only the agent, and the sidebar shows the child alone with the same text.
---
.../agent-child-row-parity.test.tsx | 36 +++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx
index 4c7c186eeb69..c8e3b4bd1900 100644
--- a/src/renderer/src/components/agent-child-row-parity.test.tsx
+++ b/src/renderer/src/components/agent-child-row-parity.test.tsx
@@ -352,6 +352,42 @@ describe('a child reads the same in the sidebar and the chat strip', () => {
})
})
+describe('a child running a shell in its turn', () => {
+ // The host records the running shell both as the child's operation and as a live command it owns.
+ const children = [
+ view('child', {
+ operation: { toolName: 'Bash', input: 'npm run dev', basis: 'open', observedAt: NOW }
+ }),
+ {
+ ...OWNED_SHELL,
+ state: 'working' as const,
+ stoppable: false,
+ firstObservedAt: NOW - MINUTE
+ }
+ ]
+ const childRow: RenderedRow = {
+ dot: 'Working',
+ lead: 'Audit the parser',
+ trail: 'Bash: npm run dev'
+ }
+
+ it('reads as the child working its shell, with the shell nested beneath it in the strip', () => {
+ const groups = buildBackgroundTaskGroupsFromViews(children)
+ expect(groups.map((group) => group.kind)).toEqual(['agent'])
+ expect(groups[0].tasks.map((entry) => entry.row.owned.map((owned) => owned.id))).toEqual([
+ ['shell']
+ ])
+ expect(stripRows(children)).toEqual([
+ childRow,
+ { dot: 'Working', lead: 'npm run dev', trail: '' }
+ ])
+ })
+
+ it('shows the sidebar only the child, saying what the strip says of it', () => {
+ expect(sidebarRows(parentWith(children))).toEqual([childRow])
+ })
+})
+
describe('one child reads the same from every shape a host publishes', () => {
it('names and details it identically from views, the subagents snapshot and the task roster', () => {
// A placeholder description falls through to the child's real label on every path.
From 193420862709c01f2fdc0f63aa44e3059a99b5b1 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 29 Sep 2026 00:41:18 -0700
Subject: [PATCH 11/11] test(sidebar): re-pin the legacy chat strip golden to
main's scoped goal-dock selector
Main's #23725 rewrote the strip's goal-dock variants from `group-has-[...]/tasks:` to an
ancestor-scoped `[[data-native-chat-background-tasks]:has(+[data-native-chat-thread-goal])_&]:`
selector. The merged head renders byte-identically to main for this fixture; only the golden
was captured before that change.
---
.../subagent-child-row-fallback-pixels.test.tsx.snap | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap b/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap
index 42013059c736..d1c70a5fee23 100644
--- a/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap
+++ b/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap
@@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`chat strip rows from a legacy background-task roster > render exactly as before 1`] = `"8 agents · 1 shell · 1 monitor "`;
+exports[`chat strip rows from a legacy background-task roster > render exactly as before 1`] = `"8 agents · 1 shell · 1 monitor "`;
-exports[`chat strip rows from a legacy background-task roster > render exactly as before 2`] = `"8 agents · 1 shell · 1 monitor "`;
+exports[`chat strip rows from a legacy background-task roster > render exactly as before 2`] = `"8 agents · 1 shell · 1 monitor "`;
exports[`sidebar child rows from a legacy subagents snapshot > render a stale parent and a lost transport exactly as before 1`] = `
[