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]
+ )
+}