docs/design/web-shell-bounded-transcript-and-subagent-details.md
Phase 1 is implemented with two deliberate differences from the target design
below. The Web Shell currently projects the main transcript in its client store
instead of requesting a server-side projection=main view, and subagent detail
uses legacy session routes resolved through the selected parent-session runtime
instead of separate workspace-prefixed routes. The remaining sections describe
the target architecture, not the complete shipped protocol.
The Web Shell currently bounds rendered DOM nodes with virtual scrolling, but the full loaded transcript still participates in several client-side costs:
subContent and subTools, even when the agent row is collapsed.This means DOM virtualization alone cannot make memory and update latency flat for very long sessions. Subagents are especially expensive because one compact row in the main transcript can retain a large, deeply nested execution trace.
The Web Shell requests bounded transcript pages and can prepend older history.
MessageList already preserves the scroll anchor while prepending and uses
@tanstack/react-virtual above a threshold. The transcript store nevertheless
has one logical block array. Its maximum-block trimming is a safety cap, not a
reloadable sliding window: it does not model gaps, independently cached pages,
or a newer-page cursor.
The public transcript API supports a cursor and beforeRecordId. This is
sufficient to walk toward older history from a known tail, but it is not a
complete contract for moving both directions after newer pages have been
evicted.
Persisted and live child events carry parentToolCallId. The transcript
normalizer retains those blocks, and transcriptToMessages attaches child text
and tools to the parent agent tool. SubAgentPanel then hides or displays this
already-materialized content. Collapsing an agent therefore saves some DOM and
Markdown work, but not transcript, message, index, or payload memory.
The existing artifact panel already provides the desired right-side layout: resizing, tabs, close behavior, and per-session panel state. The implementation should add a subagent tab kind and content renderer rather than create another competing side panel.
The client may evict any completed historical page because the persisted transcript remains available from the daemon. Eviction must never alter the stored transcript or the model's context.
The main transcript is not the complete execution trace. It is a projection containing main-agent conversation blocks and compact root-subagent summaries. Subagent detail is a separately addressable projection, keyed by the root agent tool call ID.
The client evicts completed main-agent turns and complete cached subagent detail entries, not arbitrary individual blocks. Active tools, permissions, agents, and the current turn are pinned until they reach a terminal state.
Scroll anchors, expansion state, detail tabs, and page boundaries use persisted record IDs, block IDs, tool call IDs, and turn IDs. An array index is not a stable identity after a prepend, eviction, replay, or branch change.
The main transcript shows one compact row per root subagent:
The main row does not render or retain child thinking, child messages, nested tool output, or the full subagent result. Clicking it opens a subagent tab in the right-side panel. The panel fetches the detail projection and renders:
The detail never expands inside the message flow. On a regular viewport wider than 1000px the existing right panel remains docked and resizable. On narrower viewports, and whenever the app is showing split sessions, the same panel and tab content is hosted in a right-edge floating drawer so opening details does not shrink or reflow the transcript panes.
Closing the tab releases an over-budget detailed transcript immediately; smaller completed details may remain in the short bounded cache. Reopening it fetches the complete detail again if it has been evicted.
The Web Shell should replace the single conceptual historical block list with a page table plus a small mutable live tail:
interface MainTranscriptWindow {
pages: MainTranscriptPage[];
liveTail: LiveTranscriptPage;
olderBoundary?: TranscriptBoundary;
newerBoundary?: TranscriptBoundary;
viewportAnchor?: ScrollAnchor;
}
interface MainTranscriptPage {
id: string;
blocks: readonly DaemonTranscriptBlock[];
firstRecordId: string;
lastRecordId: string;
byteSize: number;
turnIds: readonly string[];
}
interface TranscriptBoundary {
cursor?: string;
recordId?: string;
hasMore: boolean;
}
interface ScrollAnchor {
blockId: string;
offsetPx: number;
}
Historical pages are immutable. Only liveTail accepts streaming mutations.
When a turn finishes, its live blocks become immutable historical data. A live
delta must not copy every historical page.
Pages may be fetched in record-sized chunks, but eviction only removes a range whose turns are complete within the client. A boundary turn split across two responses remains pinned until its neighboring response is available.
The main projection needs a normalized, bounded summary instead of carrying a
large rawOutput object:
interface DaemonSubagentSummary {
toolCallId: string;
parentToolCallId?: string;
subagentType?: string;
description?: string;
status:
| 'pending'
| 'running'
| 'completed'
| 'failed'
| 'waiting-for-approval';
startedAt?: number;
endedAt?: number;
toolCount?: number;
tokenCount?: number;
terminationReason?: string;
requiresAction?: boolean;
hasDetails: boolean;
}
String fields must have daemon-enforced size limits. The summary must not carry the prompt, result, child tool arguments, child tool output, or child text. Root subagents appear in the main projection. Nested subagents appear only in their root detail projection.
interface SubagentDetailEntry {
sessionId: string;
rootToolCallId: string;
status: 'idle' | 'loading' | 'ready' | 'partial' | 'error';
blocks: readonly DaemonTranscriptBlock[];
byteSize: number;
lastAccessedAt: number;
}
The cache is bounded by both entry count and bytes. Open tabs and active subagents are pinned. Closed completed entries are least-recently-used eviction candidates. Detail state must not be inserted back into the main transcript window.
Extend transcript reads with an explicit projection:
GET /session/:id/transcript?projection=main
GET /workspaces/:workspace/session/:id/transcript?projection=main
projection=full remains the compatibility default for existing consumers.
Web Shell opts into projection=main.
The main projection:
parentToolCallId belongs to a subagent execution;Projection must happen before response serialization. Fetching full history and
discarding child events in React is not an acceptable lazy-loading design.
The page limit counts records visible in the selected projection, not raw
records inspected by the reader. The transcript index therefore needs a
projection-aware record sequence (or an equivalent skip index). Otherwise one
large subagent could produce a long run of empty projection=main pages.
Permission and other control-plane events are not hidden merely because their
originating tool belongs to a subagent. A pending child approval remains in the
session's bounded sidechannel state, and the root summary exposes a bounded
requiresAction indication. The main row can open the relevant detail tab;
existing global approval UI remains actionable without loading historical
child output.
Add a fresh-snapshot forward anchor alongside the existing backward anchor:
interface DaemonSessionTranscriptPageOptions {
cursor?: string;
beforeRecordId?: string;
afterRecordId?: string;
limit?: number;
projection?: 'full' | 'main';
clientId?: string;
}
Exactly one of cursor, beforeRecordId, or afterRecordId may be supplied.
The response should expose the first and last returned record IDs, and whether
older and newer records exist. Opaque cursors remain snapshot-bound; an anchor
request creates a fresh snapshot so the client can recover from an expired
cursor.
The live event stream covers records appended after the loaded tail. On reconnect or cursor expiry, the Web Shell discards affected page cursors and re-anchors by the last retained persisted record ID.
The projection is a response view only. It must not mutate the authoritative event or remove the subagent result that core/model execution consumes.
Add workspace-scoped and legacy-primary routes:
GET /session/:id/subagents/:toolCallId/transcript
GET /workspaces/:workspace/session/:id/subagents/:toolCallId/transcript
The workspace-scoped route must resolve the same selected runtime and trust boundary as the corresponding session transcript route. It must never fall back to the primary runtime when the workspace or session owner is ambiguous, unavailable, draining, or removed.
The endpoint returns the root subagent's complete ordered descendant event set
in one response, including nested subagents. It has no page cursor in the first
version. Descendant membership is the transitive closure of
parentToolCallId, not a text match and not adjacency in the file. The daemon
transcript index should maintain the parent-to-child record relationship so
opening one old subagent does not linearly replay the entire session.
interface DaemonSubagentTranscript {
v: 1;
sessionId: string;
rootToolCallId: string;
events: DaemonEvent[];
replayBoundary?: number;
partial?: true;
replayError?: string;
}
This deliberately differs from main-transcript pagination: users browse the main history incrementally, while opening a subagent opts into loading that one execution in full. A very large open subagent may therefore exceed the normal detail-cache byte target. The open entry remains pinned for correctness and is released when its tab closes instead of being partially truncated.
The response must distinguish:
In the first implementation, the existing live session stream may continue to carry child events, but the Web Shell routes them directly to an open detail entry and drops them when no corresponding detail is open. They must never enter the main window reducer. The detail snapshot response includes a replay boundary so buffered live events can be merged without a race, duplicate, or gap.
This first step removes the dominant React-state and retained-memory cost but does not remove child-event network and JSON-decoding cost. If measurement shows that active subagents still produce material transport overhead, add a separate per-client detail subscription in a later protocol change. That optimization is deliberately not required for the first version.
ToolLine should render the summary row and call an injected
onOpenSubagent(toolCallId) handler. It must no longer mount SubAgentPanel in
the main transcript. Approval indicators for a child tool remain visible on
the root summary row; opening the detail panel reveals the approving tool.
The click behavior should open details, not toggle a large inline accordion. The row can retain a small disclosure icon only if it clearly represents the right panel. Existing inline expansion state for agent rows is removed from the main transcript.
Add a subagent variant to the existing right-panel tab union. The tab stores
only session ID, root tool call ID, title, and the latest summary. Its content
component owns the detail fetch and uses the existing transcript conversion
and tool rendering primitives in detail mode.
Do not copy detail blocks into the artifact list and do not encode them into the tab object. The tab is an identity and navigation record, not a data cache.
Opening the same subagent again focuses its existing tab. Switching sessions uses the panel's current per-session state behavior. A tab restored for a session refetches details if its cache entry is no longer present.
Any expandable row inside subagent details is keyed by stable tool call or block ID. Component-local expansion state may be lost when a detail cache entry is evicted; this is acceptable for the first version and should be documented as such. Main transcript turn-collapse state must remain independent from subagent detail state.
Use both item and byte budgets. A count-only limit is unsafe because one tool result or Markdown block can be much larger than thousands of ordinary rows.
Initial defaults should be selected by benchmark rather than treated as API, but the starting test configuration is:
When pinned content alone exceeds a budget, correctness wins: keep it, record a diagnostic metric, and resume eviction after it becomes terminal. The UI must not silently truncate an active tool or permission request.
Before a prepend, page removal, expansion, or detail-panel resize, record the first visible stable block ID and its offset from the scroller top. After the layout change, restore that block to the same offset. Cache measured heights by block ID only as a rendering aid; correctness must not depend on an estimate for an unloaded turn.
Eviction must not remove the page containing the anchor or the configured neighboring pages. If the anchor record was invalidated by a branch rewrite, fall back to the closest retained record and show a non-blocking history refresh notice.
Several existing features assume that messages contains all loaded history.
They need explicit window semantics:
The first delivery may label timeline and search as “loaded messages” if a server-side locator is deferred, but branch, rewind, approval, and active-task correctness cannot be deferred.
projection=main transcript
response.SubAgentPanel expansion with the compact summary row.This phase provides a meaningful memory improvement even before the main turn window is fully sliding, because subagent traces no longer inflate the main block and message graphs.
Create deterministic fixtures containing ordinary long conversations, a few very large tool results, many small subagents, and one deeply nested large subagent. Measure at 1,000, 10,000, 50,000, and 200,000 persisted blocks:
Acceptance criteria:
| Risk | Mitigation |
|---|---|
| A collapsed row still retains full data | Project and filter on the daemon before serialization; never treat CSS collapse as lazy loading. |
| Child approval disappears from main UI | Pin active child state and reflect it in the root summary. |
| Detail snapshot races live events | Return a replay boundary and merge buffered events by stable identity. |
| Newer history cannot be restored after eviction | Add afterRecordId and fresh-snapshot anchoring before enabling bidirectional eviction. |
| Scroll jumps after page removal | Preserve a stable block anchor and never evict its neighboring pages. |
| Nested agent records leak into a sibling | Build descendant closure from indexed parentToolCallId relationships and test concurrent roots. |
| Byte budget is exceeded by active work | Pin for correctness, emit diagnostics, and evict after completion. |
| Existing SDK consumers change behavior | Keep projection=full as the compatibility default; Web Shell opts in. |
| Right panel becomes two competing systems | Extend the existing tab union and panel chrome rather than add another side panel. |
Rejected as the long-session solution. It bounds mounted DOM but retains the full transcript, derived messages, subagent trees, and indexes.
Useful only as a small rendering optimization. If subContent and subTools
remain attached to the main message graph, memory and update costs remain.
Rejected. It pays the network, parsing, normalization, and transient-memory cost that on-demand detail is intended to avoid.
Rejected. The existing right-side tabbed panel already supplies the necessary layout and session behavior.
The target architecture combines two independent bounds:
Implement the subagent projection boundary first, then the general sliding window. This order removes one of the largest and least splittable sources of main-transcript growth while establishing the projection and cache boundaries needed by the complete long-session design.