Add the agents panel: the turn's subagents and runs as a thread rail - #146
Merged
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the activity popover and mutually exclusive right-side surfaces with a unified, tabbed thread workspace for Source, Diff, and Agents.
What changed
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 fmtvp lintvp run typecheck48f598818faaf4c4261332e47a2f9638c66cd90d: format, lint, vendored Playwright check, typecheck, unit tests, browser tests, desktop build, preload verification, Release Smoke, npm dry run, CodeQL, and Vercel all passed