From 3bd627fafcda32b4f87ea9da8cf0bb01b118108b Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 23 Sep 2026 17:00:43 -0700
Subject: [PATCH] feat(native-chat): the chat strip reads the host's child
records with its parent's verdict
The strip's roster now renders from the child views its channel carries, and
passes the verdict the session's own status row gives its children, built the
way the sidebar builds it (the row's freshness and the status feed's
observation). So one child reads the same in the strip and the sidebar, live,
after the transport drops, and once the row goes stale. A roster of finished
children stays shown until the next turn but no longer animates the monitoring
indicator or blocks conversation commands.
Tests: an end-to-end run on a host with no renderer (a real hook server as the
status sink) shows the summary and the strip channel carrying the same records
at every step, the parent row folded from them, retention, and an older
client's task list holding live work only; a wired renderer test shows both
surfaces agree when live, lost and stale.
---
...ktop-renderer-runtime-capabilities.test.ts | 4 +-
...-agent-session-status-feed-test-session.ts | 4 +-
.../structured-child-records-switch.test.ts | 303 ++++++++++++++++++
.../NativeChatStructuredSession.tsx | 1 +
...NativeChatStructuredSessionStatus.test.tsx | 1 +
.../NativeChatStructuredSessionStatus.tsx | 7 +
.../structured-child-records-switch.test.tsx | 299 +++++++++++++++++
...tructured-session-background-tasks-view.ts | 17 +-
...se-structured-session-child-row-context.ts | 37 +++
9 files changed, 667 insertions(+), 6 deletions(-)
create mode 100644 src/main/runtime/structured-child-records-switch.test.ts
create mode 100644 src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx
create mode 100644 src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts
diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts
index 68f6e97e632..93d646b7b64 100644
--- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts
+++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts
@@ -8,6 +8,7 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_LAUNCH_RUNTIME_CAPABILITY,
+ AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
@@ -51,11 +52,12 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [
]
/** Gates the renderer must pass against its own main process. The Electron remote list omits all
- * five; mobile advertises the structured ones, so this is an Electron-remote gap rather than a
+ * six; mobile advertises the structured ones, so this is an Electron-remote gap rather than a
* statement that no remote client wants them. Why it is one is not recorded here. */
const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
+ AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts
index 3d7fb944735..130f5961bc0 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts
@@ -3,10 +3,10 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types'
-export function indexedStatusFeedSession(session: {
+export function indexedStatusFeedSession
(session: {
journal: AgentSessionJournal
child?: (StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' }) | null
- provider?: AgentSessionHandleProvider
+ provider?: P
}) {
return {
journal: session.journal,
diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts
new file mode 100644
index 00000000000..48358892d81
--- /dev/null
+++ b/src/main/runtime/structured-child-records-switch.test.ts
@@ -0,0 +1,303 @@
+// The switch, end to end on a host with no renderer: a Codex session's frames reach the host's
+// canonical store, and the status summary every session list reads and the chat strip's channel
+// both carry the same child records from it, while the parent row is folded from them too.
+//
+// Deliberately NOT a comparison against the provider tracker's task list: that tracker and the
+// record producer read the same child executions, so agreeing with it could not catch a defect in
+// what those executions observe. Every expectation below is written out literally.
+
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+ AgentSessionBackgroundTaskState,
+ AgentSessionStatusSummary
+} from '../../shared/agent-session-wire'
+import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view'
+import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
+import type {
+ CodexAppServerConnection,
+ CodexAppServerConnectionHandlers,
+ openCodexAppServerConnection
+} from '../codex/codex-app-server-connection'
+import { AgentHookServer, _internals } from '../agent-hooks/server'
+import {
+ HOST_TEST_SESSION as SESSION,
+ hostTestAttachParams
+} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
+import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
+import {
+ ensureStructuredAgentSessionHost,
+ stopStructuredAgentSessionRuntime
+} from './structured-agent-session-runtime'
+
+vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
+vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
+
+const THREAD = 'thread-switch'
+const REVIEWER = 'thread-reviewer'
+const ROUTES: Record = {
+ 'thread/start': { thread: { id: THREAD } },
+ 'model/list': {
+ data: [
+ {
+ model: 'gpt-test',
+ displayName: 'GPT Test',
+ hidden: false,
+ supportedReasoningEfforts: [],
+ defaultReasoningEffort: null,
+ isDefault: true
+ }
+ ],
+ nextCursor: null
+ }
+}
+
+type Row = Pick & {
+ outcome?: AgentChildWorkView['outcome']
+ tool?: string
+ owner?: string
+}
+
+/** What a surface would show of each child, independent of which channel carried it. */
+function rows(views: readonly AgentChildWorkView[] | undefined): Row[] {
+ const byId = new Map((views ?? []).map((view) => [view.id, view]))
+ return (views ?? []).map((view) => ({
+ kind: view.kind,
+ ...(view.description ? { description: view.description } : {}),
+ state: view.state,
+ membership: view.membership,
+ ...(view.outcome ? { outcome: view.outcome } : {}),
+ ...(view.operation ? { tool: `${view.operation.toolName}: ${view.operation.input}` } : {}),
+ ...(view.parentChildWorkId
+ ? { owner: byId.get(view.parentChildWorkId)?.description ?? '?' }
+ : {})
+ }))
+}
+
+describe('the chat strip and the session list read the same host child records', () => {
+ let root: string
+ let server: AgentHookServer
+
+ beforeEach(async () => {
+ _internals.resetCachesForTests()
+ root = await mkdtemp(join(tmpdir(), 'orca-child-records-switch-'))
+ server = new AgentHookServer()
+ })
+
+ afterEach(async () => {
+ await stopStructuredAgentSessionRuntime()
+ await rm(root, { recursive: true, force: true })
+ })
+
+ it('carries one record per child to both, and folds the parent row from them', async () => {
+ const connections: CodexAppServerConnectionHandlers[] = []
+ const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => {
+ connections.push(handlers)
+ const connection: CodexAppServerConnection = {
+ pid: 4321,
+ closed: false,
+ request: async (method) => (method in ROUTES ? ROUTES[method] : {}),
+ notify: () => {},
+ respond: () => {},
+ respondWithError: () => {},
+ close: async () => true
+ }
+ return connection
+ }
+ // Production's sink wiring, onto a real hook server: no renderer is involved anywhere.
+ const statusSink: StructuredAgentSessionStatusSink = {
+ publish: (summary, subject) => server.ingestStructuredStatus(summary, subject),
+ forget: (subject) => server.dropStructuredStatus(subject),
+ publishChildWork: (subject, evidence, provider) =>
+ server.ingestStructuredChildWork(subject, evidence, provider),
+ readChildWork: (subject) => server.getStructuredChildWorkViews(subject)
+ }
+ const host = await ensureStructuredAgentSessionHost({
+ stateDirectory: root,
+ hostId: 'local',
+ claimKeyId: 'key-1',
+ resolveWorkspacePath: async () => root,
+ resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
+ resolveCodexCommand: () => 'codex',
+ resolveEnvironment: async () => ({ PATH: process.env.PATH }),
+ openCodexConnection: openConnection,
+ readProcessStartTime: async () => 1_700_000_000_000,
+ statusSink
+ })
+ const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
+ attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}`
+ expect(await host.attach({ callerKey: 'switch-test' }, attachParams)).toMatchObject({
+ ok: true
+ })
+ await host.hold(SESSION, 'desktop-chat:1')
+ const summaries: AgentSessionStatusSummary[] = []
+ host.subscribeStatus({
+ id: 'session-list',
+ emit: (event) => {
+ if (event.type === 'status') {
+ summaries.push(event.session)
+ } else if (event.type === 'snapshot') {
+ summaries.push(...event.sessions.filter((session) => session.sessionId === SESSION))
+ }
+ }
+ })
+ const strip: (AgentSessionBackgroundTaskState | null)[] = []
+ host.subscribe({
+ id: 'strip',
+ sessionId: SESSION,
+ emit: (event) => {
+ if ('backgroundTasks' in event && event.backgroundTasks !== undefined) {
+ strip.push(event.backgroundTasks)
+ }
+ }
+ })
+ // Journal writes land asynchronously; let each frame's rows publish before the next.
+ const notify = async (method: string, params: Record) => {
+ connections[0]?.onNotification?.(method, params)
+ for (let tick = 0; tick < 5; tick += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+ }
+ const item = (
+ method: 'item/started' | 'item/completed',
+ threadId: string,
+ turnId: string,
+ value: Record
+ ) => notify(method, { threadId, turnId, item: value })
+ const parentRow = () => server.getStatusSnapshot().find((row) => row.structuredHost)
+ const summaryRows = () => rows(summaries.at(-1)?.children)
+ const stripRows = () => rows(strip.at(-1)?.children ?? undefined)
+ const both = (expected: Row[]) => {
+ expect(summaryRows()).toEqual(expected)
+ expect(stripRows()).toEqual(expected)
+ }
+
+ await notify('turn/started', { threadId: THREAD, turn: { id: 'p1', status: 'inProgress' } })
+ await item('item/completed', THREAD, 'p1', {
+ type: 'agentMessage',
+ id: 'lead-1',
+ text: 'Spawning a reviewer'
+ })
+ await notify('turn/started', { threadId: REVIEWER, turn: { id: 'r1', status: 'inProgress' } })
+ await item('item/started', THREAD, 'p1', {
+ type: 'subAgentActivity',
+ id: 'spawn-1',
+ kind: 'started',
+ agentThreadId: REVIEWER,
+ agentPath: '/root/review'
+ })
+ both([{ kind: 'agent', description: 'review', state: 'working', membership: 'live' }])
+
+ await item('item/started', REVIEWER, 'r1', {
+ type: 'commandExecution',
+ id: 'cmd-1',
+ command: 'npm test',
+ source: 'agent',
+ status: 'inProgress'
+ })
+ await item('item/started', REVIEWER, 'r1', {
+ type: 'commandExecution',
+ id: 'exec-1',
+ command: 'npm run dev',
+ source: 'unifiedExecStartup',
+ status: 'inProgress'
+ })
+ both([
+ {
+ kind: 'agent',
+ description: 'review',
+ state: 'working',
+ membership: 'live',
+ tool: 'Bash: npm test'
+ },
+ {
+ kind: 'command',
+ description: 'npm run dev',
+ state: 'working',
+ membership: 'live',
+ owner: 'review'
+ }
+ ])
+
+ // The parent's turn ends first: its own work is done, a subagent's is not.
+ await notify('turn/completed', { threadId: THREAD, turn: { id: 'p1', status: 'completed' } })
+ expect(parentRow()).toMatchObject({ state: 'working' })
+ expect(parentRow()).not.toHaveProperty('workingMode')
+
+ // The child finishes; the shell it launched still runs.
+ await notify('turn/completed', { threadId: REVIEWER, turn: { id: 'r1', status: 'completed' } })
+ both([
+ {
+ kind: 'agent',
+ description: 'review',
+ state: 'done',
+ membership: 'settled',
+ outcome: 'succeeded'
+ },
+ {
+ kind: 'command',
+ description: 'npm run dev',
+ state: 'working',
+ membership: 'live',
+ owner: 'review'
+ }
+ ])
+ expect(parentRow()).toMatchObject({ state: 'working', workingMode: 'monitoring' })
+ // An older client folds the summary's task list: only live work is on it.
+ expect(summaries.at(-1)?.backgroundTasks?.map((task) => task.kind)).toEqual(['command'])
+
+ await item('item/completed', REVIEWER, 'r1', {
+ type: 'commandExecution',
+ id: 'exec-1',
+ command: 'npm run dev',
+ source: 'unifiedExecStartup',
+ status: 'completed',
+ exitCode: 0
+ })
+ expect(parentRow()).toMatchObject({ state: 'done' })
+ // Finished children stay listed, with how they ended, until the session's next turn.
+ both([
+ {
+ kind: 'agent',
+ description: 'review',
+ state: 'done',
+ membership: 'settled',
+ outcome: 'succeeded'
+ },
+ {
+ kind: 'command',
+ description: 'npm run dev',
+ state: 'done',
+ membership: 'settled',
+ outcome: 'succeeded',
+ owner: 'review'
+ }
+ ])
+
+ await notify('turn/started', { threadId: THREAD, turn: { id: 'p2', status: 'inProgress' } })
+ expect(summaries.at(-1)).not.toHaveProperty('children')
+ expect(strip.at(-1)).toBeNull()
+
+ // The provider exits: the records go with the session, on both.
+ connections[0]?.onExit?.(new Error('scripted provider exit'))
+ expect(server.getStructuredChildWorkViews(parentSubject(summaries))).toEqual([])
+ })
+})
+
+function parentSubject(summaries: AgentSessionStatusSummary[]) {
+ const summary = summaries.at(-1)
+ if (!summary) {
+ throw new Error('no summary')
+ }
+ return makeStructuredAgentStatusSubject(
+ {
+ executionHostId: 'local',
+ wslDistro: null,
+ workspaceId: summary.workspaceId,
+ workspaceKind: 'git-worktree'
+ },
+ summary.sessionId
+ )
+}
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index 26f04ae2e3c..db4de629d01 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -302,6 +302,7 @@ export function NativeChatStructuredSession(
agentLabel={agentLabel}
startupPhase={hostExecution.phase}
startupChildKey={hostExecution.childKey}
+ paneKey={paneKey}
// Said once: on the pane when the failure took it, else here beside the transcript. A
// failure that names nothing is only the pane reconnecting.
error={
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx
index da53ca1914c..638ed4f054d 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx
@@ -35,6 +35,7 @@ function statusElement(
agentLabel="Claude"
startupPhase={startupPhase}
startupChildKey={startupChildKey}
+ paneKey="pane-1"
error={null}
composerError={null}
isVisible
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx
index 31518886150..c6f8316444f 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx
@@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n'
import { useDelayedStatus } from '@/hooks/use-delayed-status'
import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus'
import type { StructuredSessionBackgroundTasksView } from './structured-session-background-tasks-view'
+import { useStructuredSessionChildRowContext } from './use-structured-session-child-row-context'
type StoppingBackgroundTasks = {
sessionId: string
@@ -22,6 +23,8 @@ export function NativeChatStructuredSessionStatus(props: {
/** The host's word on the provider child; `starting` is published but not yet answering. */
startupPhase: 'starting' | 'ready' | null
startupChildKey: string | number | null
+ /** The session's own status row, whose verdict the strip's children read. */
+ paneKey: string
error: string | null
/** A read the pane is reconnecting on its own: said plainly, not as an error. */
reconnecting?: boolean
@@ -38,6 +41,7 @@ export function NativeChatStructuredSessionStatus(props: {
props.startupPhase === 'starting' ? 'starting' : null,
SLOW_STARTUP_NOTICE_DELAY_MS
)
+ const childRowContext = useStructuredSessionChildRowContext(props.paneKey)
const onStop = (taskId?: string) => {
const sessionId = props.sessionId
@@ -95,6 +99,9 @@ export function NativeChatStructuredSessionStatus(props: {
isVisible={props.isVisible}
tasks={props.backgroundTasks.tasks}
settledTasks={props.backgroundTasks.settledTasks}
+ {...(props.backgroundTasks.children
+ ? { childViews: props.backgroundTasks.children, childRowContext }
+ : {})}
indicatorActive={props.backgroundTasks.isMonitoring}
supportsTaskStop={props.backgroundTasks.supportsStop}
supportsStopAll={props.backgroundTasks.supportsStopAll}
diff --git a/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx
new file mode 100644
index 00000000000..966d8a7b0b1
--- /dev/null
+++ b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx
@@ -0,0 +1,299 @@
+// @vitest-environment happy-dom
+
+// The switch, wired end to end in the renderer: a host that publishes its child records reaches the
+// sidebar through the status bridge and the chat strip through the session channel, and both read
+// one child the same way — including once the parent's transport is lost or its row goes stale.
+
+import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+ AgentSessionBackgroundTaskState,
+ AgentSessionStatusEvent,
+ AgentSessionStatusSummary
+} from '../../../../shared/agent-session-wire'
+import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
+import {
+ AGENT_STATUS_STALE_AFTER_MS,
+ type AgentStatusEntry
+} from '../../../../shared/agent-status-types'
+import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
+import type { Tab } from '../../../../shared/tab-types'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
+import type { AppState } from '@/store/types'
+import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client'
+
+const mocks = vi.hoisted(() => ({
+ store: null as null | {
+ getState: () => AppState
+ setState: (state: Partial & { testRuntimeOwner?: string | null }) => void
+ },
+ subscribeStatus: vi.fn(),
+ unsubscribe: vi.fn()
+}))
+
+vi.mock('@/store', async () => {
+ const { createTestStore } = await import('@/store/slices/store-test-helpers')
+ const useAppStore = createTestStore()
+ mocks.store = useAppStore
+ return { useAppStore }
+})
+
+vi.mock('@/lib/worktree-runtime-owner', () => ({
+ getRuntimeEnvironmentIdForWorktree: () => null
+}))
+
+vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
+ ...(await importOriginal()),
+ runtimeEnvironmentSupportsCapability: vi.fn(async () => true)
+}))
+
+vi.mock('@/runtime/structured-agent-session-client', () => ({
+ callStructuredAgentSession: vi.fn(),
+ subscribeStructuredAgentSession: vi.fn(),
+ subscribeStructuredAgentSessionStatus: mocks.subscribeStatus
+}))
+
+vi.mock('@/components/sidebar/CacheTimer', () => ({
+ default: () => null,
+ usePromptCacheCountdownForPane: () => null
+}))
+
+import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge'
+import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSessionStatus'
+import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view'
+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'
+import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed'
+
+const MINUTE = 60_000
+const tab = {
+ id: 'structured-tab-1',
+ worktreeId: 'wt-1',
+ groupId: 'group-1',
+ contentType: 'agent-session',
+ entityId: 'session-1',
+ label: 'Codex Chat',
+ customLabel: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 0,
+ isPinned: false,
+ agentSessionAgent: 'codex'
+} satisfies Tab
+const PANE_KEY = structuredAgentSessionPaneKey(tab.id, tab.entityId)
+
+function view(id: string, overrides: Partial = {}): AgentChildWorkView {
+ const now = Date.now()
+ return {
+ id,
+ providerId: `thread-${id}`,
+ kind: 'agent',
+ description: `Audit ${id}`,
+ state: 'working',
+ membership: 'live',
+ firstObservedAt: now - 5 * MINUTE,
+ observedAt: now - 2 * MINUTE,
+ stoppable: false,
+ invocation: { invocationId: `turn-${id}`, generation: 1 },
+ ...overrides
+ }
+}
+
+function summary(over: Partial = {}): AgentSessionStatusSummary {
+ return {
+ sessionId: tab.entityId,
+ workspaceId: tab.worktreeId,
+ agent: 'codex',
+ status: 'idle',
+ hostExecutionOwned: true,
+ latestPrompt: 'fan out',
+ updatedAt: Date.now(),
+ ...over
+ }
+}
+
+function feed(): (event: AgentSessionStatusEvent) => void {
+ const call = mocks.subscribeStatus.mock.calls[0]
+ if (!call) {
+ throw new Error('status feed not subscribed')
+ }
+ return call[1] as (event: AgentSessionStatusEvent) => void
+}
+
+function row(): AgentStatusEntry {
+ const entry = mocks.store?.getState().agentStatusByPaneKey[PANE_KEY]
+ if (!entry) {
+ throw new Error('no structured row')
+ }
+ return entry
+}
+
+type RenderedRow = { dot: string; lead: string; trail: string }
+
+function readRow(root: Element, separator: string): RenderedRow {
+ const [lead, trail] = [...(root.querySelector('span.truncate')?.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 ?? '')
+ }
+}
+
+/** The sidebar's rows for the session, built exactly as the worktree card builds them. */
+function sidebarRows(): RenderedRow[] {
+ const entry = row()
+ const terminalTab: TerminalTab = {
+ id: tab.id,
+ ptyId: null,
+ worktreeId: tab.worktreeId,
+ title: tab.label,
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 0
+ }
+ const parentIsFresh = isExplicitAgentStatusFresh(entry, Date.now(), AGENT_STATUS_STALE_AFTER_MS)
+ return buildSubagentChildRows({ parentEntry: entry, tab: terminalTab, parentIsFresh }).map(
+ (agent) => {
+ const container = document.createElement('div')
+ container.innerHTML = renderToStaticMarkup(
+
+ {}} />
+
+ )
+ return readRow(container, ' - ')
+ }
+ )
+}
+
+/** The strip's rows, from the real session-status component over the channel's roster. */
+function stripRows(container: HTMLElement): RenderedRow[] {
+ return [...container.querySelectorAll('[data-native-chat-background-tasks] li')]
+ .filter((item) => item.querySelector(':scope > span.truncate'))
+ .map((item) => readRow(item, ' · '))
+}
+
+function Surfaces(props: { roster: AgentSessionBackgroundTaskState }): React.JSX.Element {
+ return (
+
+
+ undefined}
+ />
+
+ )
+}
+
+describe('the switch: both surfaces read the host child records', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetStructuredAgentSessionStatusFeedsForTests()
+ mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe })
+ mocks.store?.setState({
+ agentStatusByPaneKey: {},
+ testRuntimeOwner: null,
+ unifiedTabsByWorktree: { 'wt-1': [tab] }
+ })
+ })
+
+ afterEach(() => {
+ cleanup()
+ resetStructuredAgentSessionStatusFeedsForTests()
+ })
+
+ async function mountWith(children: AgentChildWorkView[]) {
+ // The summary and the channel carry the same views; the summary omits usage.
+ const roster: AgentSessionBackgroundTaskState = {
+ state: 'monitoring',
+ supportsStopAll: false,
+ children
+ }
+ const rendered = render()
+ await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
+ act(() => feed()({ type: 'snapshot', sessions: [summary({ children })] }))
+ const header = rendered.container.querySelector('[aria-expanded]')
+ if (header) {
+ fireEvent.click(header)
+ }
+ return rendered
+ }
+
+ it("copies the host's views onto the row and derives the legacy roster from them", async () => {
+ const shell = view('shell', {
+ kind: 'command',
+ providerId: 'codex-command:thread:thread-a:cmd-1',
+ description: 'npm run dev',
+ state: 'monitoring',
+ parentChildWorkId: 'a'
+ })
+ const finished = view('a', {
+ state: 'done',
+ membership: 'settled',
+ outcome: 'succeeded',
+ settledAt: Date.now() - MINUTE,
+ lastMessage: 'Parser is clean'
+ })
+ const running = view('b')
+ await mountWith([finished, shell, running])
+ expect(row().children).toEqual([finished, shell, running])
+ // The legacy roster lists live agents only, under the id a Codex subagent's row always had.
+ expect(row().subagents).toEqual([
+ expect.objectContaining({ id: 'codex-agent:thread-b', state: 'working' })
+ ])
+ // An idle lead held open by a live subagent reads working, from the records.
+ expect(row()).toMatchObject({ state: 'working' })
+ })
+
+ it('shows one child the same way on both surfaces, live, lost, and stale', async () => {
+ const children = [
+ view('a', {
+ operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: Date.now() }
+ })
+ ]
+ const { container } = await mountWith(children)
+ const live = { dot: 'Working', lead: 'Audit a', trail: 'Bash: npm test' }
+ expect(sidebarRows()).toEqual([live])
+ expect(stripRows(container)).toEqual([live])
+
+ // The status stream drops: the host is not heard from, so neither surface vouches for it.
+ act(() => feed()({ type: 'end' }))
+ const lost = { dot: 'No recent update', lead: 'Audit a', trail: 'No update in 2m' }
+ await waitFor(() => expect(sidebarRows()).toEqual([lost]))
+ expect(stripRows(container)).toEqual([lost])
+ })
+
+ it('reads a stale parent row the same way on both surfaces', async () => {
+ const { container } = await mountWith([view('a')])
+ // The host no longer owns the session, and its last word is older than the freshness window.
+ act(() =>
+ feed()({
+ type: 'status',
+ session: summary({
+ hostExecutionOwned: undefined,
+ status: 'working',
+ updatedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - MINUTE,
+ children: [view('a')]
+ })
+ })
+ )
+ await waitFor(() =>
+ expect(isExplicitAgentStatusFresh(row(), Date.now(), AGENT_STATUS_STALE_AFTER_MS)).toBe(false)
+ )
+ const stale = { dot: 'No recent update', lead: 'Audit a', trail: 'No update in 2m' }
+ expect(sidebarRows()).toEqual([stale])
+ expect(stripRows(container)).toEqual([stale])
+ })
+})
diff --git a/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts
index a79e97f705a..a42227f56cf 100644
--- a/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts
+++ b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts
@@ -5,21 +5,28 @@
// foreground fan-out is running work while the turn is still open. It stays
// mounted through a running turn — turn state is not a filter on the rows,
// because the producers publish only tasks they still have live evidence for.
-// Only an idle session lets it animate or speak for itself.
+// A host that publishes its child records also keeps a finished child listed,
+// with how it ended, until the session's next turn. Only an idle session with
+// live work lets the strip animate or speak for itself.
import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskState
} from '../../../../shared/agent-session-wire'
+import { agentChildWorkLiveness } from '../../../../shared/agent-status-child-work-liveness'
+import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
+import { decodeAgentChildWorkViews } from '../../../../shared/agent-status-child-work-view-wire'
export type StructuredSessionBackgroundTasksView = {
- /** The strip renders whenever the host reports monitoring — mid-turn included. */
+ /** The strip renders whenever the host reports rows — mid-turn included. */
show: boolean
/** Idle-only: gates the animated monitoring indicator and conversation
* commands, never the strip itself. A running turn owns the voice. */
isMonitoring: boolean
tasks: AgentSessionBackgroundTask[]
settledTasks: AgentSessionBackgroundTask[]
+ /** The host's child records, when it publishes them; the rows then read these. */
+ children?: AgentChildWorkView[]
supportsStop: boolean
supportsStopAll: boolean
}
@@ -29,11 +36,15 @@ export function structuredSessionBackgroundTasksView(
turnId: string | null
): StructuredSessionBackgroundTasksView {
const monitoring = backgroundTasks?.state === 'monitoring'
+ const children = monitoring ? decodeAgentChildWorkViews(backgroundTasks.children) : undefined
+ // A roster of finished children is shown, but nothing in it is running.
+ const liveWork = children ? agentChildWorkLiveness(children) !== null : monitoring
return {
show: monitoring,
- isMonitoring: turnId === null && monitoring,
+ isMonitoring: turnId === null && liveWork,
tasks: backgroundTasks?.tasks ?? [],
settledTasks: backgroundTasks?.settledTasks ?? [],
+ ...(children ? { children } : {}),
supportsStop: backgroundTasks?.supportsTaskStop === true,
// Absent means the host predates the field and does accept an untargeted
// stop; only a host that says `false` has none to offer.
diff --git a/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts b/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts
new file mode 100644
index 00000000000..c0703f5db1f
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts
@@ -0,0 +1,37 @@
+import { useMemo } from 'react'
+import {
+ agentChildRowContextForParent,
+ type AgentChildRowContext
+} from '../../../../shared/agent-child-row-model'
+import {
+ AGENT_STATUS_STALE_AFTER_MS,
+ type AgentStatusEntry
+} from '../../../../shared/agent-status-types'
+import { useNow } from '@/hooks/use-now'
+import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
+import { useAppStore } from '@/store'
+
+/** The verdict a session's status row gives its children, exactly as the sidebar derives it. */
+export function structuredSessionChildRowContext(
+ parent: AgentStatusEntry,
+ now: number
+): AgentChildRowContext {
+ return agentChildRowContextForParent(
+ parent,
+ isExplicitAgentStatusFresh(parent, now, AGENT_STATUS_STALE_AFTER_MS)
+ )
+}
+
+/** The strip's children read their parent's verdict from the same status row the sidebar reads,
+ * so one child never says "No update" in one place and "Working" in the other. */
+export function useStructuredSessionChildRowContext(
+ paneKey: string
+): AgentChildRowContext | undefined {
+ const parent = useAppStore((state) => state.agentStatusByPaneKey[paneKey])
+ // The freshness window is thirty minutes; a coarse shared tick is well inside its precision.
+ const now = useNow(30_000, parent !== undefined)
+ return useMemo(
+ () => (parent ? structuredSessionChildRowContext(parent, now) : undefined),
+ [parent, now]
+ )
+}