Skip to content

功能规格说明:消息渲染系统 ​

创建日期:2026-03-24

用户场景与测试 (必填) ​

用户故事:消息历史显示(优先级:P1) ​

作为用户,我希望看到与 agent 的对话历史,以便我可以回顾之前的交互。

为什么是这个优先级:对于基于聊天的 agent 的核心用户体验至关重要。

独立测试:向 MessageList 组件提供消息列表并验证它们按时间顺序渲染。

验收场景:

  1. 假设有消息列表,当渲染时,则它们按时间顺序显示。
  2. 假设有较长的消息历史,当渲染时,则仅显示最近的消息以维护性能和可读性。
  3. 假设有历史消息,当渲染时,则它们使用 Ink 的 Static 组件渲染以避免不必要的重新渲染。

用户故事:动态块渲染(优先级:P1) ​

作为用户,我希望看到活动工具执行或运行命令的实时更新,以便我知道 agent 正在工作。

为什么是这个优先级:在长时间运行的操作期间为用户提供即时反馈。

独立测试:将工具块的阶段从 running 更新为 end 并验证 UI 相应更新。

验收场景:

  1. 假设一个处于 running 阶段的工具块,当渲染时,则它显示动态展示(如加载指示器或进度指示器)。
  2. 假设一个正在运行的 bash 模式命令(!ls),当渲染时,则其输出实时更新。
  3. 假设任何其他块类型(如 text、error),当渲染时,则它被视为静态内容。

用户故事:多种块类型支持(优先级:P1) ​

作为用户,我希望不同类型的内容(文本、代码、错误、图片、工具调用)被适当地渲染,以便我可以轻松区分它们。

为什么是这个优先级:确保复杂 agent 响应的清晰度和可读性。

独立测试:渲染包含每种块类型之一的消息并验证它们都正确显示。

验收场景:

  1. 假设一个 stage === "streaming" 的文本块,当在折叠模式下渲染时,则它显示最后 30 个字符,灰色并截断(与工具和推理块流式行为一致)。
  2. 假设一个 stage !== "streaming"(如 end 或未定义)的文本块,当渲染时,则它使用 Markdown 渲染显示。
  3. 假设用户消息中的文本块,当渲染时,则它显示完整内容,无论 stage 如何。
  4. 假设一个错误块,当渲染时,则它以红色显示并带有 "Error:" 前缀。
  5. 假设一个工具块,当渲染时,则它使用专门的 ToolDisplay 组件。
  6. 假设一个工具块处于 start 或 streaming 阶段,当渲染时,则状态点显示为灰色(执行中、结果未定);处于 running 阶段时显示黄色;处于 end 阶段时按结果显示——成功为绿色、失败为红色。
  7. 假设一个图片块,当渲染时,则它显示图片的占位符或摘要。

用户故事:IDE 插件 Edit 差异预览(优先级:P1) ​

前 3 个故事描述 CLI 终端渲染;本故事描述 IDE 插件侧的工具结果展示。作为 IDE 插件用户,我希望在 AI 修改文件后直接在对话中看到修改前后的差异对比,以便快速审阅 AI 的改动。

为什么是这个优先级:差异审阅是用户判断 AI 改动是否正确的主要依据,直接影响对改动的信任与采纳。

独立测试:让 AI 使用编辑工具修改文件,验证对话中出现红绿差异视图;在编辑工具的权限确认出现时,验证确认框中同样展示该差异预览。

验收场景:

  1. 假设 AI 完成了一次文件编辑,当工具结果渲染时,则显示差异对比:删除的内容以红色标识、新增的内容以绿色标识、未变更的上下文以灰色标识。
  2. 假设改动在文件中的位置较集中,当差异渲染时,则未变更的长上下文自动折叠,仅保留变更前后各约 3 行并以省略号标示省略部分。
  3. 假设改动内容较长,当差异渲染时,则预览区域限制最大高度并可滚动查看。
  4. 假设编辑工具正在流式输出参数,当结果尚未完成时,则不渲染差异预览,避免展示不完整内容。
  5. 假设编辑工具请求用户权限确认,当确认框出现时,则确认框内展示同样的差异预览,用户审阅后再决定是否批准。
  6. 假设编辑前后内容完全一致,当差异渲染时,则显示"No changes"空态。

用户故事:IDE 插件 Write 文件预览(优先级:P2) ​

作为 IDE 插件用户,我希望在 AI 写入文件后看到写入内容的折叠预览与结果摘要,并能一键在编辑器中打开该文件,以便快速核对与跳转。

为什么是这个优先级:写入预览提升可核对性,但写入结果摘要已能提供基本信息,故次于差异预览。

独立测试:让 AI 使用写入工具创建文件,验证对话中出现文件标题、结果摘要与内容预览;点击文件名或打开按钮,验证在编辑器中打开该文件。

验收场景:

  1. 假设 AI 完成了一次文件写入,当工具结果渲染时,则显示文件标题(工具名与相对路径)与结果摘要(如写入行数)。
  2. 假设写入成功,当预览渲染时,则显示写入内容的折叠预览,底部以渐变淡出提示内容被截断。
  3. 假设用户点击文件路径或打开按钮,当点击发生时,则在编辑器中打开对应文件。
  4. 假设写入工具正在流式输出参数,当结果尚未完成时,则仅显示文件标题,不渲染内容预览。
  5. 假设写入参数无法解析,当渲染时,则仅显示文件标题,不渲染内容预览。

用户故事:工具文件路径相对化显示(优先级:P1) ​

作为 WebView 用户(VS Code/JetBrains/桌面端),我希望 read、edit、write 工具标题中的文件路径在 agent 工作目录(workdir)内时显示为相对路径,不在 workdir 内时才显示绝对路径,以便标题简短易读,且与终端(CLI)中的显示保持一致。

为什么是这个优先级:文件工具是 agent 最频繁的操作之一,深目录下的绝对路径会占据大量标题空间,且与用户熟悉的 CLI 行为不一致(同一会话中 CLI 与 IDE 显示不同路径会让用户困惑)。现有实现仅对正斜杠路径生效,Windows 下(workdir 与文件路径为反斜杠)前缀匹配失败,实际显示为绝对路径,与 CLI 相悖。

独立测试:渲染包含 read/edit/write 工具块的消息,验证标题中的路径按 workdir 相对化显示(在 workdir 内为相对、之外为绝对),并验证点击路径仍以原始路径打开文件。

验收场景:

  1. 假设 文件位于 workdir 内(如 workdir=/home/user/repo、文件=/home/user/repo/src/index.ts),当 工具标题渲染时,则 显示相对路径 src/index.ts。
  2. 假设 文件位于 workdir 之外,当 工具标题渲染时,则 显示绝对路径。
  3. 假设 workdir 与文件路径的分隔符风格不一致(Windows 反斜杠与正斜杠混合),当 工具标题渲染时,则 仍正确显示相对路径,与 CLI 行为一致。
  4. 假设 文件路径与 workdir 相同,当 工具标题渲染时,则 显示 .,与 CLI 行为一致。
  5. 假设 workdir 未知(宿主尚未提供),当 工具标题渲染时,则 显示原始路径。
  6. 假设 用户点击工具标题中的路径,当 点击发生时,则 仍以原始(绝对)路径打开文件,相对化仅影响显示。

用户故事:跨会话测量高度缓存(优先级:P1) ​

作为 WebView 用户(VS Code/JetBrains/桌面端),我希望消息列表记住每条消息的实测高度,以便切换会话或刷新页面后总高度与视口位置立即准确,而不是从粗略默认值开始逐行测量收敛、滚动条跳动数秒。

为什么是这个优先级:长会话下高度收敛的逐行测量链(每次测量缩小 spacer → 重新锚定底部 → 挂载下一行)会让恢复后的滚动条持续跳动,直接损害会话恢复体验。

独立测试:渲染消息列表使行被测量后,将 wave-webview:measured-heights:v1 写入 localStorage;重新加载页面后验证相同消息 id 的行直接以缓存高度渲染,无收敛过程。

验收场景:

  1. 假设 行被测量过,当 测量发生时,则 该消息 id 的实测高度与折叠状态写入 localStorage(400ms 防抖批量写)。
  2. 假设 页面重新加载或切换会话,当 虚拟列表估算行高时,则 命中缓存的 id 直接使用缓存高度;未命中的行使用已测行的平均值。
  3. 假设 用户展开/折叠了推理或压缩块,当 行重新测量时,则 缓存条目被覆盖为新高度 + 新折叠状态;折叠状态与默认渲染状态不符的缓存条目(如展开后重载、默认折叠)不被信任,直到重新测量。
  4. 假设 缓存条目超过 2000 条,当 新测量写入时,则 按插入顺序淘汰最旧条目。
  5. 假设 存储不可用或数据损坏(隐私模式、配额超限、非法 JSON),当 读写缓存时,则 优雅回退到内存缓存,列表功能不受影响。

用户故事:首开会话钉底——测量增长方向(优先级:P1) ​

作为 WebView 用户(VS Code/JetBrains/桌面端),我希望首次打开一个从未缓存过的会话时,视口钉在真实底部,以便最后一条消息完整可见,而不是停在估计高度上留下数百到数千像素的空白。

为什么是这个优先级:初始估计高度(默认 200px)低于工具密集型会话的真实平均行高时,挂载窗口的实测会持续增长 spacer 总高度;若钉底追逐只在固定帧数内进行(8 帧 chase),测量增长尚未结束时追逐已放弃,视口停在旧总高上且无后续恢复机制(实测偏移 578~2198px)。测量缓存命中后该问题消失,但任何首次打开的会话(或全局平均值偏离较大的会话)仍会触发。现有合成测试只覆盖估计高于真实的收缩方向(浏览器 clamp 自动修正),增长方向无覆盖。修复机制对齐 OpenCode(生产验证):① scrollToFn 在滚动前先写 spacer 高度,滚动目标按当前总高 clamp(消除旧高度短钉);② spacer 尺寸变化事件驱动持续重钉(无帧预算上限),覆盖追逐退出后到达的增长波;③ 虚拟器内部尺寸补偿产生的伪滚动事件标记为程序滚动,不被误判为用户上滚。注:OpenCode 的 initialOffset=MAX_SAFE_INTEGER 方案经评估弃用——它使内部偏移超出末尾、wasAtEnd 补偿跟随总高,但会让收敛延迟约 200ms,迟到的测量修正(估算→实测的缩小)落在流式 baseline 之后,破坏"流式期间不向上回跳"不变量。

独立测试:加载行高显著高于估计值的合成会话(如 300 条约 700px 高的消息),初始加载完成后滚动容器 scrollHeight - scrollTop - clientHeight ≤ 2px,且钉底过程跨越 spacer 持续增长的阶段。

验收场景:

  1. 假设 会话从未被测量过(空缓存),当 初始加载完成时,则 视口钉在真实底部(scrollHeight - scrollTop - clientHeight ≤ 2),即使挂载窗口测量导致总高度持续增长。
  2. 假设 钉底跟随期间(含流式/思考内容持续增长、每块更新都触发程序重钉)用户向上滚动,当 用户的滚轮/触控板向上输入发生时,则 立即停止重钉,不把用户拉回底部——上滚意图须在输入事件同步识别(不等浏览器合成 scroll 事件:合成事件在后续渲染帧派发,密集的流式重钉可能先执行而把视口拉回),仅当用户主动滚回底部附近才恢复跟随。
  3. 假设 总高度因测量/流式持续变化,当 spacer 尺寸每次变化且用户未上滚时,则 事件驱动持续重钉(无帧预算上限,对齐 OpenCode),最后一次变化后视口保持在底部。
  4. 假设 行测出比估计矮(如 30px 用户行 vs 200px 默认估计),当 虚拟器补偿产生伪下滚事件时,则 该事件不被误判为用户上滚(不锁死 userScrolledUp),后续重钉照常进行。程序滚动产生的 scroll 事件在渲染帧之后才派发(实测约 70% 晚于下一帧 rAF),因此程序滚动标记须由滚动事件消费清除而非单帧复位,伪滚动事件不得参与用户意图判定。

边界情况 ​

  • 消息超过 10 条怎么办? 系统应该只渲染最后 10 条消息,以确保终端保持响应且回滚不会变得过多。
  • forceStatic 为 true 或视图被展开怎么办? 所有块,包括最后一条消息中的块,都应该渲染为静态内容,禁用任何动态更新或加载指示器。这确保视图在展开时保持"冻结"和高性能。
  • 块类型未知怎么办? 系统应该优雅地处理,通过忽略它或显示通用占位符,以防止整个 UI 崩溃。
  • IDE 插件差异预览中超大改动怎么办? 未变更上下文折叠展示,预览区域限制最大高度并可滚动,避免长 diff 撑开对话流。
  • IDE 插件工具参数解析失败怎么办? 差异/写入预览优雅降级:差异预览不渲染,写入预览仅显示文件标题,不影响消息其余内容。
  • 测量缓存与折叠状态不符怎么办? 缓存条目按折叠状态校验,状态不匹配(如展开后重载、默认折叠)时视为失效,回退到平均值直到该行重新测量。