Skip to content

Add the agents panel: the turn's subagents and runs as a thread rail - #146

Merged
badcuban merged 37 commits into
mainfrom
feat/agents-panel
Aug 12, 2026
Merged

Add the agents panel: the turn's subagents and runs as a thread rail#146
badcuban merged 37 commits into
mainfrom
feat/agents-panel

Conversation

@badcuban

@badcuban badcuban commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Replaces the activity popover and mutually exclusive right-side surfaces with a unified, tabbed thread workspace for Source, Diff, and Agents.

What changed

  • Adds an Agents panel for Codex and Claude subagents plus background runs, with live status, hierarchy, model/provider identity, timing, token metadata, attention states, and compact completed history.
  • Adds transcript drill-in using the chat's own activity, markdown, file chips, tool receipts, and instruction treatment. Codex fork transcripts discard inherited parent turns using the child thread creation clock.
  • Rebuilds the thread's right rail as persistent tabs with a flat launcher, responsive sheet mode, per-thread open state, adaptive icon-only tabs, and shared Source/Diff/Agents navigation.
  • Makes Source and Diff availability truthful: clean working trees remain useful in Source, while empty Diff and Agents entries read quietly without becoming disabled.
  • Adds turn-level agent trackers, durable completed-agent history, clickable transcript file references, and de-noised child-owned activity.
  • Fixes right-rail interaction details found during functional testing, including collapsed-tab close feedback, menu/toast layering, loading states, spacing, scrolling, and window-control clearance.

Why

Agent activity had outgrown the header popover and inline receipts: concurrent work, completed history, transcripts, and cross-provider attribution needed a durable surface. Source and Diff also competed for one right-side slot, so opening one could erase another surface's state. The shared tab model keeps those workflows reachable together and gives agent work a stable place to inspect.

User impact

Users can monitor and revisit a thread's full agent fleet, inspect child transcripts, move between source changes and diffs without losing panel state, and understand when a surface is empty before opening it. Empty entries remain clickable so their full empty states and controls are still available.

Validation

  • vp fmt
  • vp lint
  • vp run typecheck
  • Focused browser coverage: AgentsPanel 36/36, FileViewerPierre 11/11, ChatView 117/117
  • Exact-head GitHub CI at 48f598818faaf4c4261332e47a2f9638c66cd90d: format, lint, vendored Playwright check, typecheck, unit tests, browser tests, desktop build, preload verification, Release Smoke, npm dry run, CodeQL, and Vercel all passed

The header activity chip now opens a right-panel tree instead of a popover.
Each subagent and background run is a branch off a provider-hued trunk:
status-ordered (running, waiting, failed, completed), with the task line,
freshest output line, and mono meta per branch. Subagents with transcripts
drill in to the existing inspector; run branches toggle their terminal and
keep a small stop control. The timeline's turn activity row gains a compact
per-turn summary (segment bars + "4 subagents · 1 done · 1 needs you") that
opens the panel.

Plumbing: the panel shares the right-panel slot with source control via an
agents=1/0 search param (open panel wins the slot, closing falls back to
prior source control state), gets its own 400px width and storage key, and
reuses the generalized auto-hide-on-narrow-layout hook. ChatView publishes
live turn state through a small bridge store (same shape as the file
viewer's) so the route-mounted panel stays in sync. Shared run/subagent
display helpers moved from ThreadActivityPopover into threadActivity.ts so
the popover, panel, and timeline row read identically.
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
threadlines Skipped Skipped Aug 12, 2026 8:54pm

Request Review

@github-actions github-actions Bot added size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Aug 11, 2026
… X always visible

Opening source control now writes agents=0 explicitly instead of only
stripping the key, so the panel's per-thread memory stops reopening it over
source control. The panel's close button is no longer mobile-only: once the
turn ends the activity chip disappears and the X is the only way to close.
The agents panel and source control were two panels fighting for one slot,
each with its own toggle and its own remembered state. They are now two tabs
of one rail: a text tab row in the panel header (so it keeps the drag region
and the window-controls-overlay paddings on desktop), the live node moved onto
the Agents tab, and one close X at every width.

The header's source control toggle becomes a single Panel toggle. It opens the
rail on the tab the thread was last left on, defaults to Changes, and works on
any thread at any time -- including threads with nothing running, which get the
Agents tab's empty state. The activity chip still deep-links to Agents.

A one-tab rail keeps its panel's plain title instead of a tab row, so General
Chats (no source control) and drafts (no turn) look exactly as they did.

Closing the rail from either tab now records both tabs closed, so no remembered
state or default-open setting reclaims the slot behind the dismissal.

The rail is one draggable slot, so it carries one width and one storage key,
defaulting to the agents tree's 400px; the separate agents width key is gone.
Its sheet form is the same 400px partial overlay on narrow layouts.

Spawned agents also get the thread provider's glyph on their branch row, and
the drill-in selection moves into the store so the conversation can open the
rail already pointed at one agent.
A turn with four subagents used to push four commentary cards and four result
cards through the conversation, burying the thread's own reply. The timeline now
carries none of the live commentary -- the turn's activity row already says how
many agents are running, and the rail carries the detail -- and a finished agent
lands as one receipt row: status dot, name, the first line of its result, the
SUBAGENT tag and a mono meta. Clicking it opens the rail drilled into that
agent's transcript.

Receipts are derived from the timeline's own result entries, not from live
progress, so they survive the end of the turn.
Follows the closeRightPanelSearchParams change: dismissing the rail from the
Changes tab now also writes the agents key closed, so the assertion covers the
whole cleared shape.
The panel only ever rendered the current turn's live subagents, so it
collapsed to an empty state the moment a turn settled and receipts for
finished agents stopped resolving. The turn-scoped progress read model is
one question ('what is this turn doing'); this adds the other ('what has
this thread run') from the same durable activity projection, and unions
them in the panel with the live record winning.

Also: a subagent's own tool calls no longer land in the conversation's
activity receipt or its counts, one live status line sits under the turn
tracker row, the closed panel button carries a live-agent node, and long
runs of tool calls in a drilled-in transcript fold into one receipt.
…lding

Adds the closed panel button's live-agent node and its tooltip copy, plus
tests for each behavior the round changed. Updates the two suites that
asserted the old empty-state copy.
…empty state

Codex agent history rows showed only a name and an age. Codex states a model
and effort on its `spawnAgent` item only when the spawn overrode them; every
other agent lifecycle item reports null, and the `subAgentActivity` spawn rows
carry neither. A child inherits the parent turn's selection, so the collector
now indexes what each turn was dispatched with from the turn lifecycle
activities and uses it as the floor under the item's own values. Per-child
token totals stay blank: no projected payload carries them for Codex.

Agent lifecycle rows no longer reach the conversation from either side. The
round-4 filter keyed on `sourceAgentThreadId`, which Codex's parent-side
`wait`/`sendInput`/`closeAgent` rows do not carry, so they leaked in as
"Finished subagent task - wait" and padded the receipt's action count and
summary. The timeline now drops every `collab_agent_tool_call` entry and every
task-stream entry that names an agent, which takes them out of the expanded
list, the count and the recomputed summary at once. The main model's own
commands and searches are untouched.

Right-anchored toasts sat on top of the open right panel. The panel publishes
its slot width as `--right-panel-inset` and the toast viewport offsets by it,
so a notice lands beside the panel instead of over its header.

The Agents tab said "No agents yet" while a turn was still being handed off to
the provider. It now says it is waiting on the turn in that state.
Dropping agent lifecycle rows outright also took the whole activity group with
them on a delegation-only turn, and the tracker (the segment bars, the "2
subagents - 2 done" summary and the live agent status line) hangs off that
group. That tracker is the conversation's only inline sign that agents ran at
all, so it has to outlive the rows it replaced.

The lifecycle entries are now parked on the row as `agentAnchorEntries` instead
of being discarded: still never rendered and never counted, but enough to keep
the group alive and to date it. A group with no visible entries renders as the
tracker alone - no "0 actions", which would read as if nothing happened, and no
expand toggle, since nothing was hidden from the reader. It stays clickable and
opens the Agents tab. With no tracker to show, the group renders nothing.

The tracker derivation moves up to the group so the group's own render decision
and the receipt cannot disagree about whether there is one, and an anchor-only
group no longer absorbs the live node, so the standalone working row still
carries it.
`provider.turn.preparing` is the only activity that always carries the
model selection a turn was dispatched with, and it is projected before
the provider hands back a turn id — so it lands with `turnId: null`.
Keying the selection map by the activity's own turn id therefore found
nothing for every real thread, and a Codex child agent (whose own
lifecycle items all report `model: null`) had no model or effort to show:
the agents panel's history rows rendered the relative time alone.

Hold the unscoped selection instead and attribute it to the next turn to
appear in the stream, skipping turns that already have one so a late
activity tagged with an older turn cannot steal it. The held selection is
applied as a floor, before whatever the activity itself states, which
also recovers the effort on Claude threads: their `provider.turn.started`
restates the model but never the effort.

The fixtures claimed a `turnId` on the preparing row that the projection
never sets; both lifecycle rows are now copied from a real thread.
The turn activity row's tracker read only the live turn-agents state,
which is scoped to the latest turn and drops settled agents once that
turn ends. On a cold load it is empty, so every settled turn lost its
tracker: a turn that did nothing but delegate was left with an anchor row
that rendered no segment bars, no count, and nothing to click.

Consult the thread's agent history alongside it — the same records the
panel's history section lists, each already tagged with the turn that
spawned it — and let the live record win per agent, since only it carries
streaming status. Mid-turn nothing changes; after a reload the history is
the only source, which is what the row is drawn from now.

The delegation-only browser test drove this from live state, which is the
one shape the row is almost never seen in; it now uses the cold-load one.
A turn's work can be split into several activity groups by anything that
lands between them, and each group derived the tracker from its own
entries — so a turn with two groups drew the same bars and the same "2
subagents · 2 done" twice. Now that a group can exist as the tracker
alone, the repeat reads as a duplicated row rather than as more detail.

Decide ownership where the rows are built and all of them are in order:
the first group a turn appears in takes the turn's tracker, and the group
carries the turns it owns. Later groups in the same turn render without
one, and a group that had nothing but the tracker to show renders nothing
at all. The first group is also where the live status line belongs while
the turn is running, since that is where the turn's story starts.
… row

Four corrections from testing the sidebar on the Windows desktop app.

The tabs were flex-1, so the strip's look was decided by how many tabs
happened to be open: one made a bar across the panel, three made slivers.
They now size to their labels the way the internal browser's tabs do --
capped, shrinking to a floor that still holds an icon and the close ✕, and
never growing to fill. The leftover strip space stays empty, and the `+`
sits against the last tab and travels with it rather than docking at the far
edge. With nothing open it is alone at the left of the row.

On Windows the strip shared the row the window controls overlay, and padded
itself clear of them, which at 330px spent most of the width the tabs had.
The panel now opens with an empty drag row of titlebar height and puts the
tabs at full width underneath. Outside the overlay it is one row as before.

An agent row's meta line moves under the name instead of sharing its
baseline on a 45% leash, so `model · effort · tokens · time` fits at 330px.
The extra line comes out of the row's padding, not its height.
…ee lines

The tabs go back into the row the window controls overlay, padded clear of
the cluster as before. Content-sized tabs are what makes that share workable:
stretched ones had to divide whatever the padding left, which was the
complaint. The separate drag row is gone.

A tab label is never abbreviated. A tab is exactly as wide as its icon, its
whole label and its ✕, at every panel width -- the shrink floor that could
produce "Cha…" is gone. Measured, the three labels come to ~252px plus 26 for
the `+`: room over at the 330px default, and past the 272px floor, where the
last tab clips rather than shortens. Clipping is the deliberate trade; the
answer when it bites is the browser panel's horizontal scroll.

Agent rows drop the objective line. It mostly restated the agent's own name,
and it cost a full line of prose in a 330px panel; it stays on the row as a
tooltip and in the drill-in. Each row is now the name, the mono meta, and one
signal line: the step for a live agent, the result for a finished one, the
served URL for a run. That last move is why a finished agent's result now
comes through `output` rather than `task`. Padding tightens to py-2 and the
dividers soften, so a three-line row is 70px against the old 88px.
The right sidebar had the pieces but not the manners: it was boxier, more
indented and more tabbed than the left sidebar it sits opposite. Measured
against that reference, this round:

- Scrolls the tab strip instead of clipping it. Three full-label tabs come to
  ~253px, which the 272px floor and the Windows overlay's shared titlebar row
  cannot hold. The row now scrolls in a hidden-scrollbar ScrollArea with edge
  fades, sized by its tabs so the `+` parks beside the last one, and the `+` is
  anchored outside the scroller so it stays reachable however far the tabs have
  gone. Selecting a tab brings it into view by the smallest scroll that does.
- Splits the agent rows in two. Live work still branches off the trunk, on a
  20px tree gutter at ~64px a row; finished work goes flat -- no trunk, no arm,
  no indent, content on the panel's 12px gutter with the time on the row's own
  right edge. The trunk now ends where Earlier begins: alive is branching, done
  is filed away.
- Rebuilds the launcher as one column of ~41px rows with hairline dividers,
  and shortens the surface descriptions so none is read half-truncated.
- Pulls everything onto one 12px gutter: section labels, launcher rows, the
  Earlier rows, and the transcript header, whose back control moves onto the
  title line so the goal and meta stop hanging off a 44px indent.
- Brings transcript prose down to 12px/18px via a `chat-markdown-dense` scope,
  so a drill-in reads as an inspector rather than a second conversation.
- Quiets what should be quiet: the diff and transcript loading states are flat
  muted lines, the "no working tree changes" box and the branch chip lose their
  fills, and a completed agent's status is muted text rather than a green pill.
  Chroma is left to running, waiting and failed.
- Adds a hover/focus-only disclosure chevron to rows that open something.
- Drops the embedded Diff tab's back-to-Changes header. The strip is the
  navigation now, so the row only restated the selected tab.
Two things the panel's scrolling strip still did differently from the internal
browser's:

- The `+` stayed beside the last tab even when the tabs had nowhere to go. The
  scroller is sized by its tabs and shrinks only when it has to, which already
  gives both cases from one rule: while the tabs fit, the `+` parks after the
  last one; once they overflow, the scroller gives up exactly the pixels the `+`
  needs and it lands flush against the strip's usable right edge -- the panel
  edge, or the clearance the Windows controls cluster leaves. The measurement
  test now pins both cases, including that the `+` stays the element under the
  pointer with the tabs scrolling behind it.
- Overflow was marked by edge fades alone. It now also draws the browser strip's
  hairline overlay bar, visible whenever the row overflows rather than only while
  hovered, and composed with the fades exactly as the browser composes them. The
  four measurements that make that bar were a magic class string typed into the
  browser panel; they move to a shared MINI_HORIZONTAL_SCROLLBAR_CLASS so both
  strips read from one place and cannot drift.

The bar is an overlay, so the strip is the same height scrolling or not, which
matters because it shares its row with the window controls. Asserted across all
four measured cases rather than left to the eye.
…self

The strip's trailing element is the new-tab button, whose own padding is
already its breathing room, so the 1em the app's other titlebar rows add
only pushed it away from the controls it sits beside.
The right edge is where tabs run under the new-tab button and the window
controls, so a fade there reads as content passing beneath them. The left
edge is the panel's own boundary, where the same fade only made the first
tab look half-drawn.
…el on live agents

The tab's leading slot already changed meaning between the surface icon and
a live node, so the close control takes it over on hover rather than
reserving a column that is invisible most of the time. It gets its own hover
fill, since the cursor stays an arrow across the whole tab. Padding is wider
after the label than before the glyph, the label's tail now being the tab's
own edge.

A running agent's meta line reads model, effort, then clock: it had the
width for all three and was spending it on the clock alone.
A forked child's inherited history arrives as a synthetic turn -- Codex ids
it "rollout-N" -- carrying no startedAt, completedAt or durationMs, while
every real turn after it is stamped. Since Codex times a turn rather than an
item, the agent's opening words were the transcript's only untimed entries,
which read as every timestamp sitting one row too low. The thread's own
createdAt stands in: a required field, landing a second or two before the
replayed records. Claude was never affected; it stamps each record.
…receipt

Three things were stacked in the drill-in. The spine centred each dot in a
fixed box at the top of the row, which is where the entry printed its
timestamp, so the dot read as belonging to the time rather than to what was
said; SpineRow now takes a per-row node offset, defaulting to what every
other caller already draws. The dense markdown scope lost the first-child
margin reset to specificity, padding six dead pixels above every entry. And
the timestamp now shares the first line of prose, which the text wraps under,
so an entry is a line shorter and a long line cannot collide with it.

Tool runs fold into a receipt at any length, down to "1 action", so the
transcript is prose and receipts and nothing else.

Separately, and app-wide rather than agents-panel specific: inline code lost
its file-reference links. The walker that tokenises inline markdown skips
code and anchors by tag, but a renderer that overrides those tags hands back
an element typed by the override component, so the guard missed it and the
walker replaced the code element's text child. Reading the tag off the source
node restores every backticked reference, in the main chat as much as here.
The bar lies across the tabs themselves, so a translucent thumb let a label
show through it and the two read as one layer instead of one behind the other.
Mixed to the value the translucent thumb already resolved to over the rail, so
it looks unchanged while nothing scrolls through it.
Codex spawns a subagent by forking its parent, so the child inherits the
parent's conversation and replays it as an untimed leading turn. The panel was
rendering that as the child's own: the replayed user message became the
instruction this agent was supposedly given, when it was really what the
operator typed to the main thread, and the main thread's reply read as the
child's opening words. The child's actual instruction is the spawn prompt,
which the panel already carries as the agent's objective.
…uction

Agent prose cites files as backticked paths, which rendered as clickable code
showing the whole raw path. In the 330px panel that wraps mid-token and reads as
a broken button, and it is a second style of clickable file next to the chip the
conversation uses for a file link.

Inline-code references in the transcript now render through the same
MarkdownFileLink and the same label. Links and inline references share one
pre-pass, so a file cited either way gets the same parent-suffix
disambiguation and the same resolved kind. Clicking still routes through
openChatFileReference, keeping the workspace search that resolves bare names and
the toast when nothing resolves; the path actions that would act on a guessed
location stay off. Opt-in per surface, so the conversation is unchanged.

Also: a forked Codex child's transcript starts at its first real turn and so
carries no leading message, leaving the panel with no instruction at all. The
objective the agent was spawned with stands in. Claude children, whose first
stored record is the spawn prompt, keep using it.
One clickable-file style across the app: the conversation showed a linked path
as a chip and the same path in backticks as its raw self, two renderings of the
same thing in one message. Compact is simply what a file reference looks like
now, so the opt-in prop is gone. A document with an active search highlight
keeps its references as written -- a chip drops the highlight and shortens the
path, so the characters being hunted for could leave the page.

The drill-in header also stops repeating the objective when the instruction
block below it is already standing in with that same text.
The right sidebar's strip shares its row with the Windows window-controls
overlay, which takes ~150px of it. Two labelled tabs already overflowed there,
and the answer was a scroll: the tab you had just come from went off screen.

The strip now measures what the row actually has to spend -- its content box,
less the width the anchored `+` and the sheet's dismissal take out of it -- and
compares it against a hidden, always-labelled copy of the row. Wide enough and
every label is drawn whole; not wide enough and all of them go at once, each tab
collapsing to its glyph plus padding with an instant tooltip carrying the name.
Re-expanding waits for 12px of headroom, so dragging the panel across the
boundary settles instead of flickering. Scrolling is still there underneath, for
when even the icons overflow.

The close ✕ moves off the glyph's slot and onto the tab's right edge, overlaid
rather than given a column: the icon stays put, the tab is the same box hovered
as at rest, and the label's tail fades out from under the ✕ the way Chrome's
does. In icon-only mode only the active tab shows a ✕, taking its whole 28px box
-- an inactive glyph is a place to go, not a place to close -- and middle-click
closes any tab in either mode, so no panel width strands one open.

The source-control surface is "Source" now. Not "Git", which would be wrong in a
Jujutsu repo the contracts already model, and not "Changes", which the file list
inside the surface is already called. Internal names stay `sourceControl`.
The X sat 2px off the right edge, close enough to read as falling out of the
tab. It moves in to 4px with the tab's trailing padding widened to match, and
the label's fade shifts with it so the name still gives way exactly where the
button starts.
The launcher's rows recited three fixed sentences regardless of what was
behind them. They now report the thread's real state, and a surface with
nothing in it drops to the muted treatment secondary text already uses.

Nothing becomes inert: every row still opens, and an empty surface lands
on its own empty state. Diffs and agents appear and vanish while a turn
runs, and a control that flickers dead is worse than one that opens onto
nothing.

Source is the working tree alone, so a clean tree really is empty there.
Diff is not: its mode picker also holds "All chat changes" and one entry
per turn, all of which outlive a commit, so it only reads as empty when
the tree is clean and no turn left a diff behind -- and a tab already
aimed at a file or a turn is never dimmed at all.

The state is assembled where the sidebar is composed, from data the
routes already hold: the refcounted git status the chat column and the
Source surface share, the thread's turn diff summaries, and the agents
source the panel itself renders from. No new query, no new subscription,
and the launcher stays a pure projection of what it is handed.
A clean tree is a fact about the tree, not an empty surface: branching,
committing, pushing and opening a pull request are all reasons to open Source
with nothing changed, so it reports the quiet tree in its description and stays
lit. Only Diff and Agents dim.

The Source panel's own Diff button carried no explanation when it greyed out,
and could not have: the button's base style drops pointer events while
disabled, so a tooltip on it would never open. It is aria-disabled now, which
keeps it hoverable and focusable to say "Nothing to diff yet".
The row carried a pulsing dot and a duration with nothing between them naming
what was pulsing. It now reads the provider, how many agents are working, how
many want something, and how much of the list is already behind you -- Codex ·
2 running · 1 needs you · 1 earlier. Terminal and detected runs count as runs
rather than folding into the agent count, since a dev server on the tree is not
an agent and would inflate the one number here worth acting on.
…amps

The inheritance arrives in two shapes and the earlier fix only knew one. Codex
replays older history as a turn with no times, which that fix dropped, but the
parent turn that was live at the moment of the fork comes across with an
ordinary id and a real startedAt -- from before this thread existed -- so the
rule slid past it and the child still opened with the operator's own message
and the main thread's reply.

What separates inherited turns from the child's own is neither their ids nor
their missing times but the clock: a turn that started before the child was
created cannot be the child's. Measured against two real forks, inherited turns
start 11s before creation while own work begins 1s and 2s after it, so the
comparison is strict rather than fuzzy.
Toasts were the only thing in the app above z-50, the layer every menu,
popover, dialog and sheet sits on. A toast lands in the top-right corner, which
is exactly where the thread panel's tab menu opens into, so an update notice
arriving at the wrong moment quietly ate the click meant for the menu item
underneath it. An ambient notice yields to whatever the user is actually doing.

The test asserts the layer rather than hit-testing it: fixed positioning in the
harness resolves against a wider ancestor than the window, so the toast renders
past the right edge where elementFromPoint cannot reach it. Verified it fails
against the old z-100.
- Keep empty Diff surfaces clickable while matching the panel launcher’s quiet state
- Give icon-only tab close controls a visible hover target
@badcuban
badcuban merged commit 8d1ef31 into main Aug 12, 2026
15 checks passed
@badcuban
badcuban deleted the feat/agents-panel branch August 12, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant