The client indexes tool/code-dispatch events into ConversationSnapshot.codeDispatches (parent callId -> ToolResultNode-shaped sub-calls; live mux and history replay build the identical index). ChatView renders each run_code parent as the new code variant (description summary, program as the expanded monospace body) with its sub-dispatches as always-visible indented rows — every sub-row dispatches through the SAME keyed conversation.chat.toolview hole with the same GenericToolCard fallback, so custom registrations (bash sample) take over sub-rows exactly as top-level rows. The details panel resolves sub-callIds to full logged args and complete output through the native path. Evidence: fixture turn 64 + built-bundle jsdom snapshot, real-machinery jsdom suites (nesting, error state, details, running parent, reference stability), and a recorded code-mode browser e2e round (keyless replay + aria golden). Scaffold gains a toolsMode patch knob.
4.5 KiB
Agent Note: Code Mode chat rendering — sub-calls as native rows under the parent
Status: implemented
English | 中文
Scope: how the web chat view renders a
run_codeturn — the client-side half of the Code Mode UI stack, built on the host foundation (full-contenttool/code-dispatch, the requireddescriptionparameter). The toolview dissolution owns the slot model this rides on.
Problem
With Code Mode enabled, the chat view showed one opaque run_code row: raw program text as the summary, sub-calls invisible everywhere. The settled product requirement is the opposite: each sub-call must render identically to a native tool call — same row components, same custom registrations, same details panel — while the transcript stays honest about the fact that the model made ONE call.
Decision
Sub-calls are ToolResultNodes indexed off the surface flow, rendered through the same keyed slot as native rows, nested always-visible under their parent.
- Data layer:
Session.applyEventSideEffectsfolds each in-windowtool/code-dispatchintoConversationSnapshot.codeDispatches: ReadonlyMap<parentCallId, readonly CodeSubCall[]>, whereCodeSubCallISToolResultNode(the sub-call id ascallId, the logged args JSON-stringified intocall.argsRaw, the full loggedcontent/isError). Live mux frames and history replay build the identical index (rebuildDerivedFromWindowclears and re-derives; copy-on-write per-parent arrays keep snapshot references memo-stable). Sub-calls never joinnodes— the surface flow remains exactly the model-visible turn structure. The event is narrowed structurally at the wire-consumer boundary (dsh-tools' host types cannot enter the client program — the host/clientContextmerges collide), the same posture as every cross-wire payload. - Render layer:
ChatView'sCallRowrenders the parent, then — for parents present in the index — a[data-subcalls]nest ofSubCallRows, each dispatching through the SAME'conversation.chat.toolview'keyed hole withentryKey = sub-tool nameand the sameGenericToolCardfallback. Identity with native rows holds by construction: a keyed registration (e.g. the bash sample) takes over sub-rows exactly as it takes over top-level rows, with zero registration changes. Running parents (runningCalls) nest their so-far dispatches the same way, so sub-rows stream in live during the run (PR1 logs each dispatch as it completes). run_codepresentation: a newcoderow variant (classifierrun_code → code,Codetitle,IconCodeOutline16) summarizes with the model-authoreddescriptionand expands to the program itself (monospace on the markdown code-block fill) rather than the args JSON envelope.- Details panel:
materialForfalls through nodes → runningCalls → the dispatch index, so a selected sub-callId resolves to full args and complete output through the identical rendering path as a native settled call.
Alternatives considered
Sub-calls flat in the surface flow (fold them into nodes). Rejected: misrepresents the transcript — the model made one call; nesting under the parent preserves the code↔calls association and keeps the fold's model-visible-order invariant untouched.
Hidden until the parent row expands. Rejected by product decision: the sub-calls ARE the story of a Code Mode turn; hiding them re-creates the opacity this feature removes. The parent's expand toggle reveals only the program.
A dedicated sub-call row component. Rejected: the whole point is identity with native rows; a parallel component would drift. The nest wrapper (indent + left edge) is the only sub-call-specific chrome.
Consequences
Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same selectedCallId channel (group membership tests both levels). Trajectory/waterfall still render run_code as a single row — their sub-call spans are deferred to the PR that adds dispatch timing (start/end events), without which a waterfall span would be a lie. Fixture turn 64 (?fixture) plus the code-mode-round browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom suites pin the slot dispatch, error states, details resolution, and index reference stability.