# Agent Note：Code Mode 的 chat 渲染——子调用作为父行之下的原生行

Status: implemented

[English](2026-07-26-code-mode-chat-subcall-rows.md) | 中文

> 范围：web chat 视图如何渲染一个 `run_code` 轮次，即 Code Mode UI 堆叠 PR（Pull Request）链的 client 侧一半，构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.md)之上（携带完整内容的 `tool/code-dispatch`、必填的 `description` 参数）。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.md)所有。

## 问题

启用 Code Mode 后，chat 视图过去只显示一条不透明的 `run_code` 行：摘要就是原始程序文本，子调用则处处不可见。已敲定的产品要求恰恰相反：每个子调用都必须与原生工具调用渲染得*完全一致*——同样的行组件、同样的自定义注册、同样的 details 面板——同时 transcript（文本记录）仍须如实反映模型只发起了一次调用这一事实。

## 决策

**子调用是 surface 流之外单独索引的 `ToolResultNode`，经由与原生行相同的 keyed slot 渲染，以始终可见的方式嵌套在父行之下。**

- **数据层**：`Session.applyEventSideEffects` 把窗口内的每条 `tool/code-dispatch` 折入 `ConversationSnapshot.codeDispatches: ReadonlyMap<parentCallId, readonly CodeSubCall[]>`，其中 `CodeSubCall` 本身就是 `ToolResultNode`（子调用 id 充当 `callId`，已记录的参数经 JSON 字符串化写入 `call.argsRaw`，完整记录的 `content`/`isError` 原样携带）。live mux 帧与历史回放构建出同一份索引（`rebuildDerivedFromWindow` 先清空再重新推导；逐父级的写时复制（copy-on-write）数组保持快照引用 memo 稳定）。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。该事件在 wire 消费方边界作结构性收窄（dsh-tools 的 host 类型进不了 client 程序——host/client 两侧的 `Context` 声明合并会冲突），姿态与所有跨 wire 载荷一致。
- **渲染层**：`ChatView` 的 `CallRow` 先渲染父行，随后对索引中出现的父级渲染一组 `[data-subcalls]` 嵌套的 `SubCallRow`，每一行都经由同一个 `'conversation.chat.toolview'` keyed 孔位、以 `entryKey = sub-tool name` 分发，并共用同一个 `GenericToolCard` fallback。与原生行的同一性由构造保证：一个 keyed 注册（例如 bash 样例）接管子行与接管顶层行的方式完全相同，注册本身零改动。运行中的父调用（`runningCalls`）也以同样的方式嵌套目前已产生的分发，因此子行在运行期间实时流入（PR1 在每次分发完成时即记录该分发）。
- **`run_code` 的呈现**：新增一种 `code` 行变体（分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`），以模型撰写的 `description` 作摘要，展开后显示程序本身（在 markdown 代码块的填充底色上以等宽字体呈现），而非参数的 JSON 信封。
- **details 面板**：`materialFor` 按 nodes → runningCalls → 分发索引的顺序逐级回落，因此被选中的子调用 callId 会经由与已完结的原生调用完全相同的渲染路径，解析出完整参数与完整输出。

## 曾考虑的替代方案

**把子调用平铺进 surface 流（折入 `nodes`）。** 否决：这会歪曲 transcript——模型只发起了一次调用；嵌套在父行之下既保住代码↔调用的关联，也让 fold 的模型可见顺序不变式原封不动。

**隐藏子调用，展开父行后才显示。** 由产品决策否决：子调用正是一个 Code Mode 轮次的核心内容；把它们藏起来，等于重新制造出本功能所要消除的那种不透明。父行的展开开关只用于显示程序本身。

**专用的子调用行组件。** 否决：本功能的全部要义就在于与原生行保持同一性；一个平行组件必然漂移。嵌套包装层（缩进 + 左侧边线）是子调用唯一的专属 chrome。

## 后果

自定义 toolview 注册免费适用于子调用——而且是刻意为之：不存在按注册粒度的 opt-out，唯一的出路是组件自行读取自身上下文，而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行（分组归属判断会同时检验两个层级）。trajectory/waterfall 仍把 `run_code` 渲染为单独一行——它们的子调用 span 推迟到增加分发计时（start/end 事件）的那个 PR；缺少计时，waterfall 上的 span 就是在撒谎。fixture（测试前置数据）的轮次 64（`?fixture`），加上 `code-mode-round` 浏览器 e2e（录制的真实 round、无密钥回放），共同锁定整个表面；jsdom 套件则锁定 slot 分发、错误状态、details 解析与索引引用稳定性。
