Skip to content

refactor(sidebar): one subagent row for CLI and structured children, shared with the chat strip - #22565

Open
brennanb2025 wants to merge 9 commits into
brennanb2025/c4-codex-producerfrom
brennanb2025/c7-shared-subagent-row
Open

brennanb2025 wants to merge 9 commits into
brennanb2025/c4-codex-producerfrom
brennanb2025/c7-shared-subagent-row

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 10 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1359 $\color{#cf222e}{\Huge{\mathbf{−}}}$​13 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1346
Prod 25 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1142 $\color{#cf222e}{\Huge{\mathbf{−}}}$​430 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​712

ELI5

A subagent shows up in two places: as an indented row under its parent in the sidebar, and as a row in the chat's background-task strip. Until now each place decided on its own what the row says, and they disagreed. A subagent that finished but left a dev server running vanished from the sidebar, and the strip showed it as "done" with the dev server in a separate group. Finished and failed subagents never showed in the sidebar at all, neither surface said which tool a subagent was running, and under a quiet parent every sibling showed the parent's clock instead of its own. This PR makes one piece of code decide what a subagent row says (the dot, the name and the detail line) and one component draw it in both places. A subagent row from a chat session now reads exactly like a subagent row from a CLI agent, including the yellow monitoring icon.

Nothing on screen changes at this PR's position, deliberately. This PR moves both surfaces onto one row model and pins today's rendering byte-for-byte (the first commit's golden, captured on the unmodified renderer). The host publishes no child views until #22614, which flips both surfaces to the records and makes every screenshot in this body live. Merging this PR alone is a pure, pixel-identical refactor.

The row code is provider-agnostic, but rows only exist where a producer registers children: Claude and Codex multi-agent v2 render after #22614, while Codex's default mode (GPT-5.5, which announces helpers only through collabAgentToolCall) shows no rows until #22619 also lands.

Stacked on #22553 (Codex producer, head f098d8db9e), which is stacked on #22536 and #22521. The base branch is brennanb2025/c4-codex-producer. This PR takes over the display half of #21400 (a subagent row's own clock). The field half of #21400 is the record's observedAt (#22521), and its CLI clock stamps belong to a follow-up PR (CLI subagent clocks), not open yet.

Merge order

What Changed

One row model, shared by every surface (src/shared/agent-child-row-model.ts)

AgentChildRowModel holds what one child row shows: displayState (an AgentStateDot state), name, a structured detail, the clocks (firstObservedAt, observedAt, recencyAt, settledAt), totalTokens, canStop, settled, and owned (the work this child owns, nested).

buildAgentChildRowModels(views: readonly AgentChildWorkView[], context: AgentChildRowContext): AgentChildRowModel[]
buildLegacyAgentChildRowModels(subagents: readonly AgentSubagentSnapshot[], context: AgentChildRowContext): AgentChildRowModel[]
buildLegacyTaskRowModels(tasks: readonly AgentSessionBackgroundTask[], settledTasks: readonly AgentSessionBackgroundTask[]): AgentChildRowModel[]
flattenAgentChildRowModels(rows): AgentChildRowModel[]   // owners before what they own
agentChildRowContextForParent(parent: Pick<AgentStatusEntry, 'updatedAt' | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'subagentObservation'>, parentEvidenceFresh: boolean): AgentChildRowContext
type AgentChildRowContext = { parentEvidenceFresh: boolean; transportObservation: 'live' | 'unverifiable'; parentObservedAt: number; hostClockOffsetMs: number }
type AgentChildRowDetail =
  | { kind: 'operation'; toolName; input? } | { kind: 'monitoring' } | { kind: 'message'; text }
  | { kind: 'ended' } | { kind: 'no-update' } | { kind: 'role'; agentType } | { kind: 'reason'; state }
  • From views (the host's child records, feat(agent-status): child work records say what the child is doing, how it ended, and when #22521): the display state comes from deriveAgentChildDisplayState plus the child's owned liveness. That is the same fold that decides a CLI parent's row, so an idle or finished child whose own shell still runs reads monitoring. Top-level rows are the main agent's children. A child's shells and nested agents hang under it in owned.
  • Freshness reuses resolveAgentChildWorkFreshness. Any live claim (working, monitoring, waiting, blocked) under a stale parent or a lost transport reads unverifiable. A settled outcome is history and is never rewritten. agentChildRowContextForParent builds the one context a parent row gives its children: its freshness verdict, its subagentObservation, and its clock. Every surface that lists one parent's children builds the context there, so a lost child reads the same everywhere.
  • Detail, in the order a CLI agent row decides it:
Display state Detail
unverifiable "No update in Nm", measured on the child's own clock
monitoring "Monitoring background tasks". The tool line is suppressed, and a monitoring row leads with it, as a CLI parent does
working / waiting Tool: input, else the last message, else the role
blocked / done / failed the last message (the reason, the result or the error), else the role
interrupted (cancelled) the role only
idle, settled with outcome unknown "Ended" (new string)
idle, live (parked) the role
  • From the legacy subagents snapshot (an old host, or any CLI pane): the same derivation, with today's inputs. There is no clock of the child's own (recency is the parent's, as today), no outcome and no operation.

One component, rendered by both surfaces (AgentChildRowContent)

The component draws the dot, the name and the detail. Only the surface tone, the separator and the hover title are passed in by the surface. The words come from one formatter, agent-child-row-text.ts, which reuses the CLI row's own phrasing (formatAgentToolPreview, agentStateLabel('monitoring'), agentNoUpdateLabel, formatAgentTypeLabel).

  • Sidebar (buildSubagentChildRows): builds its context with agentChildRowContextForParent(parentEntry, parentIsFresh). It reads entry.children when a host publishes views, and subagents otherwise. It stops collapsing monitoring → working and done → idle, and stops using the parent's clock as the child's (evidenceObservedAt = the child's observedAt). The row's DashboardAgentRow data now carries the model as childRow. The compact row renders AgentChildRowContent for it. The full row still renders through the CLI parent's own code, fed by the synthetic entry: workingMode: 'monitoring', and toolName/toolInput from the operation. From childRow directly it takes the dot, the fallback label for an unlabeled child, and the message line (agentChildRowMessageLine: the child's message, or "Ended"). Shells and monitors are not listed in the sidebar; they appear through their owner's dot, as today.
  • Chat strip (NativeChatBackgroundTasksStatus): every row, agents, shells and monitors, renders AgentChildRowContent. Two new optional props. childViews builds the rows from views, with owned rows nested under their owner. childRowContext?: AgentChildRowContext is the session's parent-row context, the same one its sidebar rows read, passed to buildBackgroundTaskGroupsFromViews(views, context?). Without it, every live claim stands as reported, which is the only behaviour today. Without it, rows come from tasks/settledTasks exactly as before. The kind icon, tokens, elapsed time and Stop stay local to the strip. A settled row whose host reports when it settled shows its run frozen at settledAt (see Architecture review). The groups and the header are unchanged: the header counts top-level rows, and a failed child reads as the host's blocked and a cancelled one as idle, the words an old strip uses for them.
  • Settled time: lastEnteredDoneAt for a subagent row is the child's settledAt, so both sidebar modes time a finished child from when it ended.

Supporting changes

  • AgentStatusEntry.children?: AgentChildWorkView[]: a type-only field. No producer writes it and no parser admits it; feat(native-chat): the chat strip and the sidebar read the host's child records #22614 populates it.
  • deriveAgentChildDisplayState, agentChildWorkOwnedLiveness and AgentChildDisplayState moved from agent-status-child-work-view.ts to a new agent-status-child-work-display.ts. The view module's owner resolution reaches agent-status-subject → agent-status-run → agent-hook-relay (node:crypto). Importing the display functions from there made the renderer boot blank in the dev app. The existing renderer-node-builtin-boundary test fails on the commit before the split. The view module keeps the host-side projection. Three test files from feat(agent-status): child work records say what the child is doing, how it ended, and when #22521/feat(native-chat): Codex sessions write their subagents into the host status store #22553 changed only their import lines.
  • agentRowDisplayDotState(agent) is the one dot rule for a dashboard row: a child's own state first, then an interrupted turn, then its state. getAgentDotState and the full row both use it. The compact summary's state order now includes failed, so a failed row can never drop out of the counts.
  • i18n: components.agentChildRow.ended ("Ended"). Added to en.json by the sync script and by hand to es/fr/ja/ko/zh. en-runtime-required.json needs no entry (literal fallbacks).

Why

A subagent has been materialised differently for each surface. The sidebar converted the strip's DTO and lost facts on the way (monitoring → working, done → idle, the parent's clock instead of the child's). The strip had its own row, with its own words. Unifying the record host-side (#22521, #22536, #22553) does not help if each surface still derives its own row from it. This PR puts that derivation in one model and one component, so both surfaces read the same row from the same record, and a structured subagent reads the same as a CLI one. For a structured child the sidebar and strip rows are the same row by construction. The parity test below asserts it for every state.

Alternatives considered:

  • Keep each surface's renderer and only share the state mapping. Rejected: the name and detail wording would still be two derivations, and that is the bug class this removes.
  • Render the strip through the sidebar's full CLI row. Rejected: the strip has kind icons, tokens and Stop, and the CLI row has send-target, lineage and dismiss controls. Sharing the dot, name and detail piece is what parity needs.
  • Map outcomes into the CLI entry's fields only (for example interrupted: true for a cancel). Rejected: the CLI wording "Interrupted by user" would overclaim for a child a parent cancelled, and there is no failed or "Ended" state to map onto. The row carries its own display state, and row.state keeps the lifecycle word every other reader of a CLI row understands (failed → blocked, cancelled/unknown → idle).

Linked Issue

None — part of the structured chat status/orchestration program.

Visual Proof

Captured with the $electron skill: a dev instance launched with ORCA_BACKGROUND_LAUNCH=1 on a throwaway profile, Playwright over CDP, and screenshots of the hidden window. No window was shown and no OS input was used. The fixture store: one folder workspace with three agent panes seeded through setAgentStatus.

  • Parent A (fresh): seven child views. Working with Bash: npm test -- parser (started 12m ago); working with Read: src/parser/index.ts (3m); finished while its npm run dev shell still runs; finished; failed; cancelled.
  • Parent B: stale 40m. Two children last heard from 1m and 25m ago.
  • Parent C: a CLI pane that sends only subagents.

The chat strip is the real NativeChatBackgroundTasksStatus, mounted into the running renderer with the same children, because no structured session was running. Before is the base commit (1df7e358c0), fed the legacy shapes the shared legacy projections derive from the same views. After is this branch, fed the views. Both runs used the same seed script. The shots predate the review-fix commit 3422ccaf4c; no scenario that commit changes is in frame.

What each pair shows (images below):

Shot Before After
Compact sidebar card (*-04-sidebar-card.png) A's live children show only general-purpose / Explore; its four settled children are absent. B's children both read "No update in 40m" (the parent's clock). A: Run the parser tests - Bash: npm test -- parser; Summarize the grammar - Grammar has 42 productions (green check, 6m); Monitoring background tasks - Start the dev server (yellow Activity icon, no tool text); Fuzz the tokenizer - Exit code 1: fuzz target crashed (red); Benchmark the lexer (interrupted); Map call sites - Read: src/parser/index.ts. B: "No update in 1m" and "No update in 25m".
CLI pane C (same card) Explore, Rename token kinds - general-purpose, Approve the write - Agent identical
Full sidebar mode (*-05-sidebar-card-full-mode.png) titles and start times only tool steps (Bash npm test -- parser), result and error lines, the monitoring icon on the dev-server child, finish times
Chat strip (*-02-strip-top.png, *-03-strip-bottom.png) "6 agents · 1 shell". Start the dev server reads done (green check); its shell sits in a separate Shell group; Fuzz the tokenizer · failed; no tool, result or error text. "6 agents — 2 working, 1 monitoring, 1 blocked, 1 stopped, 1 done". Run the parser tests · Bash: npm test -- parser; Monitoring background tasks · Start the dev server, with npm run dev nested under it; Fuzz the tokenizer · Exit code 1: fuzz target crashed; settled rows said "ended Nm Ns ago" at this capture; they now show a frozen run duration (see Architecture review).
Whole window (*-01-window-compact.png, *-06-window-full-mode.png) context context
Before After
Compact sidebar card before-04-sidebar-card after-04-sidebar-card
Full sidebar mode before-05-sidebar-card-full-mode after-05-sidebar-card-full-mode
Chat strip, top before-02-strip-top after-02-strip-top
Chat strip, bottom before-03-strip-bottom after-03-strip-bottom
Whole window, compact before-01-window-compact after-01-window-compact
Whole window, full mode before-06-window-full-mode after-06-window-full-mode

Testing

All tests were run with env -u ORCA_STRUCTURED_SESSION, rebased onto #22553's f098d8db9e. The broad run below is at 47f8ddbd1d. After the review fixes (3422ccaf4c), the sidebar, native-chat and dashboard suites, the row-model test and the boundary test pass: 4,766 tests, all green.

  • pnpm tc:node, tc:web and tc:cli all exit 0.
  • Every suite the change can reach: src/shared/agent-status*, src/shared/agent-lead-status-fold, src/main/codex, src/renderer/src/components/{sidebar,native-chat,dashboard}, src/renderer/src/lib, src/renderer/src/i18n, and the renderer boundary test. 1,355 files, 13,308 tests: 13,292 passed, 1 expected-fail, 14 skipped, 1 failed. The two timing tests that failed across two runs of that set, codex-session-index-heal-state (75 s) and agent-resume-launch-target (cmd.exe case, 30 s), both pass when run alone (18/18). The branch does not touch either file.
  • New tests:
    • subagent-child-row-fallback-pixels.test.tsx is the first commit, captured on the unmodified renderer. It pins the compact rows (fresh, stale parent, lost transport), the full rows, and the expanded strip built from legacy subagents and tasks/settledTasks, as markup snapshots plus row text. It passes unchanged after the refactor, so old hosts and CLI panes render identical markup.
    • agent-child-row-model.test.ts (21 tests): the display table for every display state; tool text suppressed while monitoring, even with a stale operation present; freshness (a stale parent or lost transport makes live claims unverifiable and leaves settled outcomes alone); sibling clocks; nesting and host order; names and roles; stop eligibility; the legacy path.
    • agent-child-row-parity.test.tsx (21 tests): the same views rendered through the compact sidebar row and through the chat strip give the same dot, name and detail for all 12 states (including a live parked child), each also checked against a literal expected table. The full sidebar row is checked for every state too: its dot label, its name, and the detail it shows ("Ended" included), or the text it must hide. Also covered:
      • an unlabeled failed child named "Failed" on all three rows;
      • a lost transport and a stale parent, with the same views, read the same on the sidebar and the strip (the strip given agentChildRowContextForParent of the same parent);
      • the monitoring icon on the full row, and no tool text on a monitoring child on either surface;
      • sibling clocks through the real row ("No update in 0m" / "10m");
      • a settled child timed from settledAt on both surfaces;
      • strip nesting and Stop by provider id.
    • worktree-card-agent-summary.test.ts (+1): a failed child stays in the summary groups and counts.
  • Ablations at the final head. Each is a single edit, asserted to apply exactly once, and restored from HEAD. The test set is the new tests, the existing child-row, strip and summary tests, and the renderer boundary test (99 tests).
Deleted or restored Red
The monitoring → working collapse restored in the sidebar child rows 3: monitoring parity scenario, full-row monitoring icon, no tool text on a monitoring child
Owned-work liveness input to the child fold 8
Child evidence clock on the sidebar entry 2 (sibling clocks, full-row unverifiable)
Child recency borrowing the parent clock (substitution) 5
Legacy row detail 3 (two fallback goldens + legacy model test)
settledAt as a child's finish time 1
Nested owned rows in the strip 1
Compact child rows skipping the shared component 1 ("Ended" parity)
Freshness on the derived child display 4
The "Ended" detail 2
A monitoring row leading with its state 1
The row model reaching the host-side view projection again 1 (renderer node-builtin boundary)
The strip ignoring the parent context it is given 2 (lost transport, stale parent)
The full row's message line ignoring the child row 1 ("Ended" on the full row)
failed removed from the summary order 1
The full row's unlabeled fallback using the collapsed state 1
  • Quality. The changed-code gate reports 0 new findings in every category since f098d8db9e. Full oxlint on the 22 changed TS/TSX files exits 0, and a 1,200-line probe confirms it reports max-lines. pnpm run audit:anti-slop exits 0. All four verify:localization-* scripts pass, and oxfmt --check passes on the six catalogs. pnpm-lock.yaml is absent from the range, and no docs were added.

  • Platforms: macOS only (tests and the hidden-window dev instance). The change is renderer and shared presentation code with no platform branches.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Review

Not verified:

Decisions and deviations:

  • A working or waiting row prefers the tool line over the child's latest words. A CLI parent row leads with its tool line too, and CLI parity is the requirement.
  • Waiting shows the tool the approval is for (Edit: src/a.ts) with the "Waiting for input" dot label. That matches the CLI row, where the plan table said "Waiting for input" + tool name.
  • A cancelled child shows its role and no message. That follows the plan's "—": the last words of a cancelled child can be mid-thought.
  • An unverifiable row says "No update in Nm", the CLI row's words, measured on the child's own clock, with "No recent update" as the dot label.
  • A row with no label, role and agent type restating it falls back to the dot's state label. That is today's sidebar rule, now on both surfaces for views. A legacy strip row keeps its own kind label ("Background agent").
  • Row identity on the view path is the host's childWorkId; the legacy path keeps today's provider ids. A resumed child (settled → live, same id, generation + 1) keeps its row. When feat(native-chat): the chat strip and the sidebar read the host's child records #22614 switches a session from legacy to views, a row's key changes once: a Codex agent's view providerId is its thread id, not codex-agent:<thread>. Stop names providerId; Codex agents are not stoppable, so no stop call sees the difference.
  • The strip header counts top-level rows, so a shell a child owns is counted under its owner, not as "1 shell". Header words for outcomes are the legacy ones, so the header is unchanged.

Architecture review

An architecture pass at 04d1d403f0 asked for four changes and one note. Commits 21fcd597e6 and b4cf0d2a91 make them.

1. One legacy builder family, one label rule, one detail rule. The strip used to build its old-host rows by hand in background-task-roster.ts. Those rows had their own placeholder set and their own three-state detail rule. That made three builders of AgentChildRowModel with two copies of the naming rule. One child whose description was the placeholder task got three names: '' from views (the surface then showed its state), task from the sidebar's snapshot path, and Background agent from the strip's roster path. Now:

  • buildLegacyTaskRowModels(tasks, settledTasks) lives in agent-child-row-model.ts beside the other two builders. It also keeps the resumed-task dedupe (the live row wins).
  • All three builders name a row through one exported rule, usableAgentChildLabel, with one placeholder set. The transcript row's resolveBackgroundTaskName uses it too.
  • All three decide detail through agentChildRowDetail. A roster row declares evidence: 'run-state': the host reported only a run state, so the row says that state's reason word or nothing. That is exactly today's output.
  • The module header says when each legacy builder is deleted. The snapshot builder goes once CLI panes publish children and no supported paired host predates that. The roster builder goes once no supported paired host predates child views on the session feed.
  • The new parity test takes one child, a working agent with description task and role Explore, and renders it four ways: sidebar from views, sidebar from the snapshot, strip from views, strip from the roster. All four give the same dot, name and detail. Before this change the snapshot path read task - Explore.

2. "No update in Nm" uses the parent's reader clock. agentChildRowContextForParent took updatedAt, which is the host's wall clock. For a mirrored parent, the parent row decays on this machine's receipt time (agentStatusEvidenceObservedAt), while its children subtracted the host's clock from this machine's now. With a host running 20 minutes fast, the parent read "No update in 3m" and its snapshot children read 23m. Now:

  • The context takes agentStatusEvidenceObservedAt(parent). Its Pick is widened to evidenceObservedAt and mirroredEvidenceReceivedAt.
  • It also carries hostClockOffsetMs: the reader's clock minus the host's, measured at that evidence, and 0 for a parent observed on this machine. A view child's own observedAt is moved onto the reader's clock by that offset. Its age is then the host-clock age plus the receipt-clock age, and nothing is subtracted across two machines. observedAt itself keeps the host's clock.
  • The full sidebar row now reads the child's silence from the model (agentChildRowNoUpdateLabel), not from the synthetic entry, so the compact row and the full row agree.

3. One display-to-legacy state collapse. failed → blocked and interrupted → idle were written twice: the sidebar's agentRowStateFor and the strip header's headerRunState. Now agentChildRunStateFor in agent-status-child-work-display.ts owns it. The sidebar adds only its own step on top: monitoring becomes working plus workingMode.

4. Settled rows freeze. A settled strip row used to show "ended 6m 7s ago", and that label grew at 1 Hz. So an expanded strip holding only finished work re-rendered every second, forever. Now:

  • A settled row shows its run frozen at settledAt (settledAt − firstObservedAt). That is the live row's elapsed clock, stopped when the child finished.
  • Only live rows keep the 1 Hz tick awake, so a strip holding only settled rows lets the tick sleep.
  • The duration subtracts two stamps from the same host, so it has no clock skew either.
  • The now-unused components.agentChildRow.endedAgo key is removed from all six catalogs; this PR had added it.
  • The sidebar still times a finished child from when it ended ("3m"), as before.

5. Freshness input is still computed per surface (note, no change). agentChildRowContextForParent unifies how the context is built, but parentEvidenceFresh is still passed in by each surface from its own useNow(30_000) tick. The sidebar does this in WorktreeCardAgents, and #22614 does it in its session-context hook with the same rule and threshold. Near the 30-minute boundary, the two surfaces can disagree for up to one tick (about 30 s). The parity tests pass the same boolean to both surfaces, so they cannot see this. For structured sessions this barely matters: structuredHostOwned makes the clock test always fresh, which leaves transportObservation as the real verdict, and both surfaces read that one store fact. Computing the verdict once per entry in the store would remove the duplicated input. That is new machinery and is not done here.

Deviations, recorded:

  • An old host's unlabeled roster row keeps "Background agent". A view row with no label reads by its state. The fallback is applied in buildBackgroundTaskGroups, on the legacy path only, after the shared label rule has run. Codex publishes unlabeled agent tasks today (codex-background-task-tracker.ts), so making this row read by its state would change old-host pixels. The first commit's golden forbids that. So the four-way test uses a child that has a usable label, and the unlabeled case stays one step different between host generations, as it already was.
  • A roster row never shows a role. The run-state evidence says only the reason word, because the roster carries no role. So a new-host and an old-host row for the same unlabeled agent differ in their trail ("Working · Agent" versus "Background agent"). This is pinned by the legacy golden.
  • A live strip row's elapsed time still subtracts the host's firstObservedAt from this machine's now. A mirrored strip would show that skew. No caller passes views from another host yet (feat(native-chat): the chat strip and the sidebar read the host's child records #22614). If one does, the same hostClockOffsetMs applies.
  • Settled time is a frozen duration in the strip and "ended N ago" in the sidebar. These are two different facts, one per surface. Dot, name and detail parity is unaffected.

Validation for these commits:

  • pnpm tc:node, tc:web and tc:cli exit 0. Full pnpm exec oxlint exits 0. The changed-code gate reports 0 new findings. audit:anti-slop exits 0. pnpm-lock.yaml is not in the range, and no files are added.
  • Reachable suites (src/shared/agent-status*, the row model, src/main/codex, src/renderer/src/components/{sidebar,native-chat,dashboard}, the parity test, src/renderer/src/lib, src/renderer/src/i18n): 1,370 files and 13,480 tests. 13,465 passed, 1 expected-fail and 13 skipped. One failed: the palette-match performance budget, a timing test in an untouched file, which passes alone (9/9). The legacy fallback goldens pass unchanged.
  • Ablations at the final head. Each is one edit, asserted to apply exactly once, and restored from HEAD:
Ablated Red
Snapshot builder back to the raw description ?? agentType name 6: the four-way parity test and 5 label-rule tests
The run-state arm deleted from the detail rule 5: the four-way test, 2 strip reason/settled tests, the legacy strip golden, and the roster model test
Context back to parent.updatedAt 4: both mirrored-parent parity tests and 2 context tests
The view-child clock offset removed 2: the mirrored view test (sidebar and strip) and the context test
The full row back to the synthetic entry's clock 1: the mirrored full row
The shared collapse set to failed → done 1: the lifecycle-word parity test, sidebar and header. This ablation was green until that test was added in b4cf0d2a91.
Settled rows counted as ticking again 1: the all-settled strip commits on every tick
The settled duration measured against now 2: the frozen strip duration and the all-settled strip

In the first run of the tick ablation, WorktreeCard.pr-display also failed once, under load. The rerun at the final head reddened only the intended test, and that file passes alone.

Visual proof for these commits. Captured with the $electron skill: a hidden dev instance with ORCA_BACKGROUND_LAUNCH=1, a throwaway profile, and Playwright over CDP. The fixture store is the same one as above. Three real strips are mounted in the running renderer: the six-child views strip, the same children filtered to settled only, and an old-host roster with a placeholder-named task. Before is 04d1d403f0; after is b4cf0d2a91. Over a 3.2 s window, the settled-only strip's text changed before (ended 6m 8s ago → ended 6m 11s ago) and did not change after (5m 0s throughout). The roster strip is identical in both, including the unlabeled Background agent · no contact row.

Before After
Views strip before-strip-arch-views after-strip-arch-views
Settled only before-strip-arch-settled after-strip-arch-settled
Old-host roster before-strip-arch-legacy after-strip-arch-legacy
Whole window before-01-window after-01-window

Not verified for these commits: no mirrored (paired or SSH) host was run, so the skew fix is proven by tests only. There was no Linux, Windows or mobile run. The sidebar's placeholder-name fix is pinned by tests and not shown in a screenshot.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Wire / mixed versions: no wire change. AgentStatusEntry.children is a type-only optional field that nothing publishes or parses yet. The strip's childViews and childRowContext are optional props no caller passes yet. Old hosts, and new hosts before feat(native-chat): the chat strip and the sidebar read the host's child records #22614, send only the legacy shapes, which render as before (pinned by the first commit).
  • SSH / remote: a child's "No update" reading is on the reader's clock. It measures from the parent's receipt time when the parent is mirrored, and a view child's own host stamp is moved onto that clock by the parent's clock offset (see Architecture review).
  • Folder workspaces: the screenshots were taken in a folder workspace. Nothing here assumes a git worktree.
  • Performance: the row model is linear in children per parent. The strip memoizes groups on its inputs.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • One row model (src/shared/agent-child-row-model.ts): buildAgentChildRowModels derives a row per host child view (top-level = the main agent's children, owned work nested), and buildLegacyAgentChildRowModels reproduces today's inputs from a subagents snapshot. Display state comes from the shared child fold, freshness from resolveAgentChildWorkFreshness, and the detail string in the same order a CLI agent row decides it.
  • One component + formatter (AgentChildRowContent.tsx, agent-child-row-text.ts): the dot, name and detail piece the sidebar's compact row and the chat strip both render, with words reused from the CLI row's own formatters (formatAgentToolPreview, agentStateLabel('monitoring'), agentNoUpdateLabel, formatAgentTypeLabel).
  • Sidebar (worktree-subagent-child-rows.ts, worktree-card-compact-agent-row.tsx, worktree-card-agent-summary.ts): reads entry.children when a host publishes views and subagents otherwise; no longer collapses monitoring → working / done → idle or borrows the parent clock for the child (evidenceObservedAt = the child's observedAt); flattens to agent rows carrying the model as childRow.
  • Chat strip (NativeChatBackgroundTasksStatus.tsx, background-task-roster.ts): new optional childViews prop builds rows from views (agents, shells, monitors) with owned work nested under its owner; settled rows show "ended N ago"; stop is keyed on the provider id.
  • Dot rule + display split (agent-row-dot-state.ts, agent-status-child-work-display.ts): agentRowDisplayDotState unifies the dashboard/summary dot (child state first, then interrupt, then state), and deriveAgentChildDisplayState / agentChildWorkOwnedLiveness / AgentChildDisplayState move out of the view module so the renderer no longer reaches host-only code.
  • Supporting: type-only AgentStatusEntry.children; components.agentChildRow.ended / endedAgo added to all six catalogs; first-commit fallback goldens pin legacy pixels, and the parity test pins the two surfaces to one expected table across every state.

I read the full diff and ran the new/changed suites (agent-child-row-model, agent-child-row-parity, subagent-child-row-fallback-pixels, worktree-subagent-child-rows, background-task-roster) plus renderer-node-builtin-boundary: 76 tests pass, including the renderer boot boundary the split was made for. I also traced the legacy paths against DashboardAgentRow, worktree-card-compact-agent-row, lastEnteredDoneAt, agent-row-tool-preview and the roster header to confirm the pixel-parity claim holds.

ℹ️ Cross-surface parity is only asserted while the transport is live

The sidebar passes real freshness into the shared model (parentIsFresh, parentEntry.subagentObservation, parentEntry.updatedAt), while the strip hardcodes STRIP_ROW_CONTEXT as parentEvidenceFresh: true, transportObservation: 'live'. So for a given set of views, the same child can read unverifiable in the sidebar and working/monitoring in the strip once contact is lost — the one divergence the shared record was meant to remove. The parity test cannot catch this because both sidebarRows and stripRows only ever exercise fresh contexts, and its unverifiable scenario is a host-reported state rather than a freshness verdict. This is documented in the PR body as a follow-up, so nothing to change here; worth keeping the caveat explicit next to the parity-assertion claim so a future reader does not over-trust it.

Technical details
# Cross-surface freshness divergence is outside the parity test's reach

## Affected sites
- `src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts:107` — context built from `args.parentIsFresh` and `parentEntry.subagentObservation`.
- `src/renderer/src/components/native-chat/background-task-roster.ts:136` — `STRIP_ROW_CONTEXT` pins freshness/transport live.
- `src/renderer/src/components/agent-child-row-parity.test.tsx:115,130` — `sidebarRows`/`stripRows` take no freshness input, so parity is only ever asserted fresh.

## Required outcome
- No change required for this PR. When C5 wires the strip to a contact/liveness verdict, both surfaces should feed the same verdict into `buildAgentChildRowModels`; until then the parity test's "same by construction" claim is scoped to a live transport.

## Open questions for the human
- Does C5's strip channel carry a contact-loss verdict, or will the strip remain permanently `live` and accept the divergence?

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior review at 47f8ddbd1d, one commit (3422ccaf4c) lands the review fixes:

  • The strip reads its parent's verdict. agentChildRowContextForParent is now the one context a parent row gives its children; the sidebar uses it, and the strip takes an optional childRowContext threaded into buildBackgroundTaskGroupsFromViews (default REPORTED_ROW_CONTEXT keeps today's "claims stand as reported" until C5 passes one).
  • The full row matches the child row. Its message line comes from agentChildRowMessageLine (the child's message or "Ended"), an unlabeled child falls back to its display state, and its dot comes from agentRowDisplayDotState.
  • Failed children count. failed joins SUMMARY_STATE_ORDER, so a failed child is no longer dropped from the summary groups and counts.
  • Coverage. The parity test now asserts the full sidebar row for every state, a live parked child, an unlabeled child, and a lost or stale parent on both surfaces.

I read the new commit end to end, traced the touched consumers (DashboardAgentRow, the compact row, worktree-card-agent-summary, background-task-roster, worktree-subagent-child-rows) and ran the six affected suites (agent-child-row-parity, subagent-child-row-fallback-pixels, worktree-card-agent-summary, background-task-roster, agent-child-row-model, agent-finished-timestamp): 75 tests pass. The full-row "Ended" assertion and the lost/stale-parent parity assertions each fail without their corresponding change, so the coverage is real. The dot/lastEnteredDoneAt change from 47f8ddbd1d stays behavior-preserving on the legacy path because a legacy subagents snapshot can never carry state done (AgentSubagentState excludes it), which the fallback goldens confirm.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior pullfrog review at 3422ccaf4c, the branch rebased onto a newer brennanb2025/c4-codex-producer and one commit lands on top:

  • Extracted the subagent snapshot module (src/shared/agent-status-subagent-snapshot.ts, new): AgentSubagentState, AgentSubagentSnapshot, AGENT_STATUS_MAX_SUBAGENTS, the admission normalizer (renamed normalizeAgentSubagentsField) and agentSubagentsEqual move out of agent-status-types.ts, which re-exports them so every importer is unchanged. This brings agent-status-types.ts back under the max-lines cap.
  • Moved the shared field caps (AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH, AGENT_MODEL_MAX_LENGTH) into agent-status-field-normalization.ts, re-exported from agent-status-types.ts.
  • Rebase-only churn: the six older commits were rebased (new SHAs, identical diffs); the src/main/codex/* movement the range-diff shows is base-branch progress, not this PR's work.

I read the incremental range-diff and the full diff end to end, traced every importer of the moved symbols, and confirmed the extraction is behavior-preserving: the three caps stay in live use inside agent-status-types.ts, the new module adds no import cycle, and no import site breaks. pnpm test src/shared/agent-status-types.test.ts src/shared/agent-status-child-work-view.test.ts passes (88 tests), pnpm run typecheck:node exits 0, and oxlint reports 0 findings on the three changed files.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one defer-able observation inline. Tests and typechecks for this delta are green.

Reviewed changes

Since the prior pullfrog review at 04d1d403f0, two commits land on top:

  • One legacy builder family (src/shared/agent-child-row-model.ts): the strip's task-roster rows now come from buildLegacyTaskRowModels beside the view and snapshot builders, sharing usableAgentChildLabel and the one detail rule; background-task-roster.ts groups them and substitutes the kind label only for an unnamed legacy row.
  • One owner for the run-state word (src/shared/agent-status-child-work-display.ts): agentChildRunStateFor (failed→blocked, cancelled→idle) moves into the shared display module; the sidebar row state and the strip header both delegate to it.
  • One reader clock (src/shared/agent-child-row-model.ts): AgentChildRowContext gains hostClockOffsetMs; agentChildRowContextForParent measures the parent on the reader's receipt clock and rowFromView moves a child's observedAt onto it, so a mirrored parent's children age against one machine.
  • Frozen settled rows (background-task-roster.ts, NativeChatBackgroundTasksStatus.tsx): a settled row shows its run frozen at settledAt instead of a growing "ended N ago", and settled rows no longer arm the 1 Hz tick; the endedAgo string is removed from all six catalogs.
  • Tests: the three builders, the mirrored reader clock, and the frozen strip are pinned, plus a lifecycle-word test tying the sidebar row state to the strip header.

I read the incremental range-diff, both new commits, and the full diff end to end; ran the six affected suites, the renderer boundary test, pnpm run typecheck:node / typecheck:web (both exit 0), pnpm run verify:localization-catalog, and a broader renderer/shared run (5234 passed, 1 expected fail). One defer-able observation inline.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

),
firstObservedAt: view.firstObservedAt,
observedAt: view.observedAt,
recencyAt: view.observedAt + context.hostClockOffsetMs,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recencyAt is the only host-stamped clock this moves onto the reader's clock — firstObservedAt above it and settledAt below stay on the host's, so for a mirrored parent every age computed from them still subtracts across the two machines. That is the strip's live elapsed and single-command header elapsed, and the sidebar's started/done times; the hostClockOffsetMs comment's “no age subtracts across two machines” holds only for the “No update” reading. Latent until #22614 publishes views, so nothing to fix in this PR's shipped behavior, but worth mapping these with the same offset.

Technical details
# Only `recencyAt` is moved onto the reader clock

## Affected sites
- `src/shared/agent-child-row-model.ts:240` — `firstObservedAt` copied raw from the host view.
- `src/shared/agent-child-row-model.ts:243` — `settledAt` copied raw from the host view.
- `src/renderer/src/components/native-chat/background-task-roster.ts:178` — `backgroundTaskElapsedLabel(row.firstObservedAt, now)` subtracts a host stamp from the reader's `now` for a live row (a host running fast renders `0s`).
- `src/renderer/src/components/native-chat/background-task-header-content.ts:170` — the same subtraction for a single live command's header elapsed.
- `src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts:38,48` — the synthetic entry's `startedAt`/`stateStartedAt` come from `firstObservedAt`; `lastEnteredDoneAt` reads `childRow.settledAt`, which the full and compact rows then subtract from `now`.

## Required outcome
- For a parent with `mirroredEvidenceReceivedAt` set, a child's `now - firstObservedAt` and `now - settledAt` must read on the reader's clock, matching the “No update” reading. Mapping both fields with the same `hostClockOffsetMs` keeps the skew-free `settledAt - firstObservedAt` run duration.

## Open questions for the human
- Are these fields left on the host clock deliberately (e.g. #22614 or the producer re-stamps them), or should the model map them the way it maps `recencyAt`?

@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch from 8e22ed0 to 08ca92b Compare September 24, 2026 21:09
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c7-shared-subagent-row branch from b4cf0d2 to 5406ab2 Compare September 24, 2026 21:38
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch from 08ca92b to 358d1a9 Compare September 24, 2026 23:36
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c7-shared-subagent-row branch from 5406ab2 to 359483b Compare September 25, 2026 00:02
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch from 358d1a9 to 1db34d1 Compare September 25, 2026 04:57
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c7-shared-subagent-row branch from 359483b to f02c2e1 Compare September 25, 2026 05:13
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch from 1db34d1 to 36242df Compare September 25, 2026 05:53
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c7-shared-subagent-row branch from f02c2e1 to d30ea85 Compare September 25, 2026 05:56
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch from 36242df to bc928a2 Compare September 25, 2026 10:47
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c7-shared-subagent-row branch from d30ea85 to a26d58b Compare September 25, 2026 10:58
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch from bc928a2 to 307537d Compare September 25, 2026 20:04
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c7-shared-subagent-row branch from a26d58b to 871c4b9 Compare September 25, 2026 20:26
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c4-codex-producer branch from 307537d to e7239f1 Compare September 25, 2026 20:30
…acy 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.
…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.
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.
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.
…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.
…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.
…n 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.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/c7-shared-subagent-row branch from 871c4b9 to 05308c3 Compare September 25, 2026 20:32

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant