---
url: /wave-agent/specs/ui/message-rendering-system.md
description: >-
  Ink 消息流（静态历史 + 动态块）与 webview 消息渲染：首屏一页 + 上翻按需从转录按游标取更早（内存口径仍是压缩后的上下文；远控页页大小 60
  条、同一套翻页）、宿主对消息内容零状态（用的时候现取）、差异/写入预览、路径相对化、按像素预算的虚拟窗口、按会话恢复阅读位置、钉底跟随与用户滚动意图识别，以及对话流的表格展示与对齐（表格横向滚动四端共用）、链接角色、助手消息代码块的复制按钮、没有可渲染块的消息不占位、键盘可达与无障碍
---

# 功能规格说明：消息渲染系统

**创建日期**：2026-03-24

> **桌面端对话流渲染的作用范围（严格区分，勿混用）**：本规格后半部分（表格展示与滚动、表格对齐、链接角色与下划线时机、键盘可达与无障碍、桌面状态指示语义色）中的**视觉与布局规则只在桌面端生效**（`[data-host="desktop"]` 层），插件端（VS Code / JetBrains）保持既有观感；其中的**结构与无障碍改进**（复选框可访问名、折叠标题改为按钮并带展开态、表格包裹层及其横向滚动、键盘停靠点）在**全部宿主**（CLI 之外的四个：桌面端、VS Code、JetBrains、远控 Web 页）**共用**。规格只规定**规则与语义**，视觉取值（字号、行高、颜色、间距等）不在其内。

## 用户场景与测试 *（必填）*

### 用户故事：消息历史显示（优先级：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. **假设**一个图片块，**当**渲染时，**则**它显示图片的占位符或摘要。

***

### 用户故事：没有可渲染块的消息不占位（优先级：P2）

作为 WebView 用户，我希望一条还没有任何可渲染内容的消息在列表里完全不占位，以便时间线连线停在最后一个圆点上、列表底部不凭空多出一行空白。

**为什么是这个优先级**：这类空消息是流式过程的副产物，不是用户要读的内容。codechat 网关上的 Claude 模型思考明文恒为空（只回 signature），流式期第一个 `thinking_delta` 是空串，SDK 于是在真内容到达前几百毫秒到几秒就先建出一条 `blocks: []` 的助手消息。渲染层若照常出外壳，会出现两处偏差：时间线收尾规则（`.timeline-row--last` 那一段缩到圆点中心）落在一个根本没有 `.timeline-row` 的行上而失效，前一条真实消息的最后一行保持全高线段——用户看到「一条线一直连到列表底部，底部却没有块」；同时那一行还占着基础行高与自己的 14px 消息间距，底部凭空多出一段空白。

**独立测试**：构造 `blocks: []` 的助手消息、以及「只有空 reasoning 块」的助手消息各一条，断言列表里既没有对应的 `.message` 元素、也没有为它留的那一行（虚拟行同样不存在）；把它夹在同一轮的两条真实助手消息之间，断言连线不断开、收尾落在最后一条真实消息的最后一行上；再把它放到列表末尾，断言底部几何与「没有这条消息」时相同。

**验收场景**：

1. **假设**一条消息的块全部渲染不出内容（`blocks` 为空、空 text / 空 reasoning、只有 `file_history` / `task_notification` / 没有图片地址的 image 块），**当**列表渲染，**则**它不在渲染列表里：不产生任何 DOM 节点，不占行高与底部间距。
2. **假设**这样一条空消息夹在同一轮的两条助手消息之间，**当**列表渲染，**则**它不参与时间线 run 的起止判定：连线不断开，run 的起点与收尾落在真正带圆点的消息上。
3. **假设**空消息是一轮的末条（流式刚开始的那一刻），**当**列表渲染，**则**收尾规则落在前一条真实消息上，列表底部既不出现空白行，也不出现悬空的线段。
4. **假设**消息里带 notice 通知块（如 hook 的循环进度行），**当**判定是否为空，**则**不算空——通知行照常渲染，不能连通知一起吞掉。

***

### 用户故事：插件端 Edit 差异预览（优先级：P1）

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

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

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

**验收场景**：

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

***

### 用户故事：插件端 Write 文件预览（优先级：P2）

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

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

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

**验收场景**：

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

***

### 用户故事：工具文件路径相对化显示（优先级：P1）

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

**为什么是这个优先级**：文件工具是 agent 最频繁的操作之一，深目录下的绝对路径会占据大量标题空间，且与用户熟悉的 CLI 行为不一致（同一会话中 CLI 与插件端显示不同路径会让用户困惑）。现有实现仅对正斜杠路径生效，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/桌面端），我希望长会话里只有视口附近的若干屏真正挂在 DOM 上，上下拖动时落点立刻有内容，以便几千条消息的会话照样打得开、滚得动，视口里不出现大片空白。

**为什么是这个优先级**：窗口按「行数」扩边时，扩出来多少屏取决于行高——同样十行，短的用户消息只有一两百像素，长的工具输出可能几千像素，于是短行侧不够用（白屏）、长行侧又挂满整屏节点。按**像素预算**扩边与行高无关，一处预算同时管住两头；靠近末尾时直接渲到末行（末尾两屏内不裁剪），避免「测量 → 总高变化 → 末尾又挪进视口」的连锁空白。行高初值也要按内容给，否则工具密集的会话首屏就要收敛一大截。

**独立测试**：造一条 2000 条消息的会话，首屏渲染后断言挂载行数远小于总行数、且视口上下各有至少一屏的预留（视口内不留空白）；把 `scrollTop` 直接设到中部任意位置（模拟拖动滚动条），断言下一帧该位置已有渲染内容；把 `scrollTop` 设到距末行两屏以内，断言渲染范围一直延伸到末行。行高估算另在远控 Web 页的手机列宽（390×844 + 触控模拟）下单独测：逐行比对估算与实测之差（应落在十几 px 量级），再连续上翻穿过一批未挂载行，断言视口顶那一行**相对手指**的位移不超过个位数像素——「相对手指」= `Δ行顶 +（ΔscrollTop − 本实现自己写入的 scrollTop 量）`。直接拿 `Δ行顶 + ΔscrollTop` 当判据是无效的：那一条恒等于「这行的内容偏移变了多少」，把补偿整笔抵消掉（同一场景实测，有补偿 0px、关掉补偿 17 次共 1207px，两种写法都读成 71px）。触控手势另加一条结构性断言：手指按下期间（含中途接上更早一页）`scrollTop` 的**写入量必须为 0**，补偿量出现在行容器的 `translateY` 上，手势停稳后该偏移清空、且折算进 `scrollTop` 的量与峰值偏移一致——这一条不是「可见位移」类判据，因为桌面 Chromium 里手势中途写 `scrollTop` 并不产生可见位移，真机（iOS WebKit）才会。再补一跑「手势中途一路滑到真底」：**到界那一刻**（手指还没抬起）行容器的 `translateY` 就必须为空，最后一行底边到容器底边的间距与「本来就贴着底」时相同；抬起手指、惯性走完后仍是如此（实测偏移 4965px 会在底部留下 4985px 空白，修正后两个时刻都回到 20px）。再补一跑「上翻攒下偏移再掉头甩向底部」：把更早一页的高度与「离底距离」造成同一量级（真机轨迹是偏移 705px / 离底 368px），逐帧采样整条惯性尾巴，断言**没有任何一帧**的底部间距超过贴底基线——关掉方向反转折算时这一跑必须变红（实测 863px 偏移在离底 294px 处露出 589px 空白），否则说明这条轨迹根本没逼近底部、测的不是这件事。

**验收场景**：

1. **假设** 会话有数千条消息，**当** 列表渲染时，**则** 只有视口附近的若干屏挂在 DOM 上，两侧预留按像素预算计算（与行高无关），视口内不出现未渲染的空白。
2. **假设** 用户把滚动条拖到任意位置，**当** 落点确定时，**则** 该位置立即有内容渲染，不需要等逐行测量从估计值收敛。
3. **假设** 视口进入末尾两屏以内，**当** 列表计算渲染范围时，**则** 一直渲到末行（末尾不做虚拟裁剪），避免末尾的测量修正反复把新行挪进视口。
4. **假设** 某行还没有被测量过，**当** 列表估算它的高度时，**则** 按内容类型给初值（用户行、助手文本按字数与换行折算、工具调用、图片、推理块、压缩提示各一档），而不是一律同一高度；测得真实高度后覆盖估计值，总高度收敛过程中视口不跳。
5. **假设** 行被测过又滚出渲染范围，**当** 它再次进入范围时，**则** 沿用上次的实测高度，不重新从估计值开始。
6. **假设** 正文列宽随宿主而变（桌面端 800px 列、远控 Web 页 390px 手机列），**当** 估算纯文字行的高度时，**则** 折行按**正文列的实际宽度**计算并按**段落**分别折行——每行可容纳的视觉字符数由「正文宽度 ÷ 单个视觉字符宽」得出（中文/全角算两格、ASCII 算一格），**不得**用按桌面宽度写死的固定字数，**也不得**把整条内容的字符总数一次性折算（那会吞掉每个段落的折行余量）；正文宽度变化时，未挂载行的估算按新宽度重算，已实测的行仍沿用实测值。
7. **假设** 某行的高度从估算值被实测值覆盖（或上方接上更早的一页）、账本随之变化，**当** 该变化落地时，**则** 视口补偿必须在**同一帧、绘制之前**生效（用户看不到未补偿的中间态）；**不得**指望浏览器滚动锚定兜底：行是绝对定位的，页面上没有在流内、位置会变的锚点，实测同一场景下把 `overflow-anchor` 在 `auto` 与 `none` 之间切换、同时关掉本实现的补偿，可见位移完全一致（每次 71px），这条通路在本架构里补不了任何东西。
8. **假设** 触控手势正在进行（手指按着，或已抬起但惯性还在滚），**当** 账本变化需要补偿时，**则** **不得**写 `scrollTop`——在手势中途改它会打断惯性，且读回的 `scrollTop` 是陈旧值，用户看到的就是「滑着滑着定位突变」（真机症状，桌面 Chromium 复现不出来）；补偿必须改成行容器上的 `translateY` 视觉偏移（行是绝对定位的，平移容器不触发布局），等手势停下（手指无新事件、且 `scroll` 事件静默）后，在**同一个任务**里把这笔偏移折算回 `scrollTop` 并清空，中间态不被绘制。**边界**：`touchend` 不保证到达（touch 事件派给按下时的目标，那一行在滚动中可能已被回收、不再冒泡到容器），所以结束判据不能只看 `touchend`，必须有「touch 静默」兜底；挂着偏移期间，锚点、渲染窗口与粘性条判据都要按**屏幕上看到的**位置算（`scrollTop − 偏移`），否则锚点会落到屏幕外，视口里那几行的修正就没人管（实测漏一次上翻 142px）。**贴顶/贴底另有一条**：折算的落点会被上下界夹住，没兑现的那一截**不得**继续挂在 `translateY` 上——那等于把内容整体顶开，边界处凭空多出与偏移等高的空白（真机症状：上翻过再一路甩到底，底部一大块空白），且下一次折算依旧被夹、永远还不清；边界上没有需要锚住的东西（显示内容的头/尾本身就是对的），这一截直接丢掉、停在边界即可（实测偏移 4965px 时底部空白 4985px，改成丢弃后回到 20px 的常规间距）。**到界即折算**：丢弃不能等到手势停稳——空白的长度就等于惯性尾巴的长度（真机约 1s「等一会儿自己消失」），所以 `scroll` 里一发现贴顶/贴底且还有挂起偏移，就当场折算：此刻落点已被夹住，写 `scrollTop` 是空写、不会打断惯性，视觉上就是把内容补回边界。**方向反转同样当场折算**：上翻期间攒下的偏移如果拖到「掉头往回甩」之后还挂着，它会随着逼近边界变成越来越大的空白（空白 = `|偏移| − 离边界距离`，真机实测偏移 705px、离底 368px 时空白 358px），一直挂到撞上边界才消失——所以一旦滚动方向相对上一次发生反转（位移超过 2px 的抖动阈值）且仍有挂起偏移，就立即折算。这三条判据都是**几何**问题，不参与「这次滚动是不是我们自己写的」那套分类：自己写出来的滚动一样会把内容推到边界。

***

### 用户故事：阅读位置按会话恢复（优先级：P1）

作为 WebView 用户（VS Code/JetBrains/桌面端），我希望在长会话里上翻查看某一段之后切到别的会话，再切回来时视口还停在我刚才读的那一行，以便来回对照两个会话时不用每次重新翻。

**为什么是这个优先级**：切会话回来一律回到底部，等于每次离开都把阅读位置丢掉。而切会话本来就会把已翻出来的更早内容整体丢弃（见「首屏一页」故事），所以能恢复的落点只能是「首屏这一页里还找得到的那一行」；找不到就按首屏默认位置呈现，不为了恢复位置去重读更早的页。

**独立测试**：在一条长会话里滚到中段（明显离开底部），切到另一条会话再切回，断言视口落在刚才那一行（行首 + 行内偏移）、没有被拉到底部；在底部状态切走切回，断言仍在底部；连续切过 40 条会话后切回最早那条，断言位置已被丢弃、回落到默认呈现。

**验收场景**：

1. **假设** 用户在会话 A 里滚到中段阅读，**当** 切到会话 B 再切回 A 时，**则** 视口恢复到离开时那一行（行首定位 + 行内偏移），不回到底部。
2. **假设** 用户在会话 A 里停在底部（跟随状态），**当** 切走再切回时，**则** 仍在底部并继续跟随（新消息可见），不恢复成「已离开底部」。
3. **假设** 离开时锚定的那一行不在切回后的首屏一页里，**当** 列表恢复时，**则** 按首屏默认位置呈现，不为了恢复位置去重读更早的页。
4. **假设** 同时记住的位置超过 32 条会话，**当** 又有一条新位置写入时，**则** 按最近使用淘汰最旧，不无上限增长。
5. **假设** 恢复已经发生过，**当** 用户随后自己滚动、或新消息到达时，**则** 以用户动作为准，恢复不得再次写视口。

***

### 用户故事：首开会话钉底——测量增长方向（优先级：P1）

作为 WebView 用户（VS Code/JetBrains/桌面端），我希望首次打开一条会话时视口就在真实底部，以便最后一条消息完整可见，而不是停在按内容估出来的高度上、底下留着数百到数千像素的空白。

**为什么是这个优先级**：高度估算再按内容分档也估不准（图片、长工具输出差得最多），挂载窗口的实测会持续改变总高度；若钉底追逐只在固定帧数内进行，测量增长还没结束追逐就放弃了，视口停在旧总高上且没有后续恢复机制（实测偏移 578~2198px）。所以约定改为**由几何变化驱动**：用户处于跟随状态时，任何一次总高度变化（行测量回来、容器尺寸变化）之后都重新贴到真实底部，不设帧数预算；反过来，用户一旦用真实输入离开底部，这些重钉一次也不得执行。这里必须分清「这次滚动是谁引起的」：程序写入的视口位置、浏览器自身的 clamp、虚拟窗口因行高修正产生的补偿都会派发 `scroll` 事件，它们都不代表用户意图。

**独立测试**：加载行高显著高于估计值的合成会话（如 300 条约 700px 高的消息），初始加载完成后滚动容器 `scrollHeight - scrollTop - clientHeight ≤ 2px`，且钉底过程跨越 spacer 持续增长的阶段；流式持续期间点击吸顶用户消息条，跳转后目标消息保持居中、视口不被后续重钉拉回；用户停在距底数百像素（如 200px）处时，该距离不得被判为「在底部」——否则任意一次非程序滚动事件都会重新吸附并把视口拽回底；列表内放一个自身可纵向滚动的区域（长思考块）并把滚轮用在其上：内层吃掉的那次手势不得移动视口、也不得上锁，内层已到边界而由列表承接的那次滚动必须照常上锁。

**验收场景**：

1. **假设** 会话首次打开（还没有任何一行被测过），**当** 初始加载完成时，**则** 视口钉在真实底部（`scrollHeight - scrollTop - clientHeight ≤ 2`），即使挂载窗口测量导致总高度持续增长。
2. **假设** 钉底跟随期间（含流式/思考内容持续增长、每块更新都触发程序重钉），**当** 用户做出任何主动的滚动动作（滚轮/触控板向上、触摸滑动、键盘滚动键、拖动滚动条）时，**则** 立即暂停重钉、不把用户拉回底部——滚动意图在输入事件发生的同步时刻记录，不得只依赖浏览器合成的 `scroll` 事件（合成事件在后续渲染帧派发，密集的流式重钉可能先执行而把视口拉回）；**没有真实输入来源的 `scroll` 事件（程序钉底、虚拟器的尺寸补偿、浏览器 clamp）一律不参与「用户是否离开底部」的判定，既不上锁也不解锁**；仅当用户在真实滚动动作中回到真实底部（判定阈值取个位数像素级，与 opencode 的 10px / react-virtuoso 的 4px 同档）才恢复跟随；停在距底数百像素处一律算「已离开底部」，不得被重新吸附。**边界**：本条约定的下限是「视口已离开底部」——差值在 1 像素以内时，虚拟化实现自身的末端锚定会在行高变化时接管视口（该路径只由几何距离判定，不读滚动意图），亚像素级微推不在保证范围内。
3. **假设** 总高度因测量/流式持续变化，**当** 每一次几何变化（行测量回来、容器尺寸变化）落地且用户未上滚时，**则** 都由该变化驱动重新贴到底部（无帧预算上限），最后一次变化后视口保持在底部。
4. **假设** 行测出比估计矮（如 30px 用户行 vs 数百像素的估计），**当** 虚拟窗口因行高修正在一帧内反复写入视口（实测同一 tick 写入 160619 与 160123、相差 500px，事件的最终落点为 160103）时，**则** 该事件不得被误判为用户上滚（不锁死跟随），后续重钉照常进行。判据必须是「这次事件背后有没有真实输入」，不能是「事件落点是否等于我们写入的位置」——程序滚动产生的 `scroll` 事件在渲染帧之后才派发（实测约 70% 晚于下一帧 rAF），同一帧的多次写入还只反映为一个落点；伪滚动事件不得参与用户意图判定。
5. **假设** 视口已滚出最近的用户消息、列表顶部显示吸顶用户消息条，**当** 用户在流式输出期间点击该条，**则** 视口必须跳到该消息并使其居中并停住，此后每次流式重钉都不得把视口拉回底部——该点击与上滚同等视为用户「离开底部」的意图；直到用户主动滚回真实底部（判定阈值同上，个位数像素级）才恢复跟随。
6. **假设** 用户已上滚离开底部、正在阅读历史，**当** 出现一次显式置底请求（确认弹窗出现、对话面板/会话绑定、rewind 之后、设置保存成功、宿主下发的置底命令）时，**则** 该请求只在用户仍处于跟随状态时把视口移到底部；用户已离开底部时视口保持不动，显式请求不得覆盖用户的阅读位置。恢复跟随只有两条途径：用户自己滚回真实底部，或用户发送新消息（发消息即视为回到对话末尾）。
7. **假设** 列表内部存在自身可纵向滚动的区域（工具输出、思考内容、diff 视图、写入预览、被截断的用户内容；按「实际纵向溢出且有 `overflow-y`」判定，不依赖人工维护的标记名单），**当** 用户把滚轮/手指/方向键落在这个区域上，**则** 该手势不算「离开底部」：只要内层真的吃掉了这次滚动，列表既不移动也不上锁，跟随照常进行；**当** 内层已到自己的边界、这次滚动实际由列表承接时，**则** 必须照常上锁——嵌套排除只能抑制「输入时刻的同步上锁」，不得连输入本身一起吞掉（吞掉会让列表自己的上滚不被记录，读者随即被下一次重钉拉回底部）。

***

### 用户故事：离开底部时的「回到最新」入口（优先级：P1）

作为 WebView 用户（桌面端 / VS Code / JetBrains），我希望在上翻读历史、下方内容还在继续长的时候，消息区底部一直有一个「回到最新」的入口，以便一键回到对话末尾，而不用拖滚动条或一路滚回去。

**为什么是这个优先级**：钉底跟随在用户上翻时按约定松手，可内容并不会因此停下——读者既不在跟随状态，也看不出下面又多了多少。参考物的做法是在消息区底部居中浮一颗箭头按钮：视口离内容末尾超过 200px 就出现，离得近就收回，点击即回到底部并重新跟随，按钮下方带一层底部渐隐，让「下面还有内容」在视觉上成立。这条与上一条（钉底）是一对：那条保证不打扰读者，这条保证读者想回去时有明确入口。

**独立测试**：在长会话里把视口停在距底 600px 处，断言「回到最新」按钮出现、且可被 Tab 聚焦；滚到距底 100px 处，断言按钮收回、且不在 Tab 序列里；在距底 600px 处点击按钮，断言视口落到真实底部（`scrollHeight - scrollTop - clientHeight ≤ 2`）且恢复跟随——随后一个流式 chunk 到达时视口仍在底部；还没有任何消息时断言不渲染该入口。

**验收场景**：

1. **假设** 用户把视口停在距内容末尾 200px 以外，**当** 列表渲染完成时，**则** 消息区底部居中显示「回到最新」按钮（圆形、半透明背景、向下箭头），位置浮在内容之上、不改变任何一行的高度与位置。
2. **假设** 按钮正在显示，**当** 用户滚回距内容末尾 200px 以内时，**则** 按钮淡出、不再接收点击，也不再占 Tab 序列的位置（键盘 Tab 不会停在隐形的按钮上）。
3. **假设** 按钮正在显示，**当** 用户点击它（或聚焦后按 Enter/Space）时，**则** 视口回到真实底部并恢复跟随；到达底部后按钮自动收回，此后新的流式内容继续跟随。
4. **假设** 用户正停在底部跟随，**当** 流式内容继续增长时，**则** 不出现该按钮，视口照常跟着走。
5. **假设** 用户上翻停在距底远处、此时又有新内容追加，**当** 内容增长使距离进一步拉大时，**则** 按钮的显示状态随之更新——它不能只靠 `scroll` 事件驱动，内容增长本身不派发滚动事件。
6. **假设** 会话刚打开、还没有任何消息（欢迎页/首屏加载），**则** 不渲染该入口。

***

### 用户故事：首屏一页，上翻按需取更早（优先级：P1）

作为 WebView 用户（桌面端 / VS Code / JetBrains），我希望打开一条会话时先看到最近的那一段，往上翻时再一段段地把更早的对话接上来，一直翻到这条会话的第一条消息，以便长会话打开得快，而压缩之前的历史也仍然回得去。

**为什么是这个优先级**：列表口径收敛到压缩边界之后，「整份」最多是一个上下文窗口的量级，但它仍要在绑定的那一刻被完整构造出来，并经 stdio 送进渲染进程——Read / MCP 读进来的图片是 base64 形态，那条用了 6 周的会话就是这样打开即黑屏的（`CrRendererMain` 里的 `cppgc::internal::WriteBarrier::DijkstraMarkingBarrierRangeSlow`）。而\*\*「内存里只留压缩后的上下文」与「屏幕上能翻多深」本来就是两件事\*\*，参考物正是这么分的：它的桌面端本地会话走转录游标分页（`getTranscriptPage(sessionId, beforeUuid, limit)`，游标是 `beforeUuid`，回包 `{ found, messages, hasMore, totalRows }`），首屏只读一页、上拉再取更早，同时在加载期剪枝决定模型上下文、在内存压力下按「离视口最远」丢页。分层的意义是：模型上下文与宿主常驻量都不随会话长度增长，回看深度由用户自己翻出来——这正是我们要的形状。

**远控页同一套翻页，只是页大小不同（自 2026-11-06 起）**：那一侧隔着一层中继，单帧超过每订阅者缓冲就会被**静默丢弃**，所以首连 / 切会话取最近 `60` 条（按实测约 5.7 KB/条 ≈ 0.34 MB 折算，落在 512 KiB 预算内），上翻一页也取 `60` 条——协议与判据（`loadEarlierMessages` / `earlierMessages`、`hasMore` / `anchorLost`）与其余三端完全相同，多出来的只有一道宿主侧 512 KiB 单帧预算的裁剪（裁短不漏条，只是页更短；且裁短时宿主会把 `hasMore` 强制成 `true`，否则被裁掉的那几条再也翻不回来），以及宿主要把翻出来的页接进自己的影子列表（见边界情况「远控页的首屏上限是自己的取值」）。此前这一侧是「没有更早可翻」，那是把「一帧能装多少」误当成「能不能翻」；故事见 [remote/remote-web.md](../remote/remote-web.md)「手机上往上翻更早的消息」。

**页大小：只按条数，没有字节上限**。首屏一页与上翻一页同为 `200` 条，只用 `packages/agent-sdk/src/constants/messageWindow.ts` 里的一个常量（`MESSAGE_PAGE_SIZE`）。`200` 与参考物同值——它取最新窗口与更早一页都用 `limit: 200`，条数交给渲染侧的虚拟化扛；它也没有按字节砍页这一层。我们曾经有过一道 `4 MiB` 字节预算，后来删了：它数的是**还没剥离工具图片 base64** 的字节，而那批字节在发给 UI 之前就被剥掉（`packages/code/src/stdio/uiPayload.ts`，见 [stdio-transport.md](stdio-transport.md)「工具图片的字节不下发 UI」），量的是不会上线的量，只会把带截图的会话首屏砍短。

**`limit` 封顶 `1000`**：超过 `MESSAGE_PAGE_SIZE_MAX` 时按上限收，**不报错**——参考物对同一参数做 `clamp(1, 1000)`。非法值（非正整数、`since` 与 `before` 同时给）仍在宿主侧直接失败（`packages/code/src/remote/messageWindow.ts`），两类判据不合并：写错参数要吵，写大了只是多要了点。

**首屏按条数填满，压缩边界不截断这一页**：内存里的列表从最近一次压缩开始，所以一条压缩过的会话「内存里有几条」可能远小于一页（摘要块 + 压缩保留的轮次）。因此首屏取数不是「把内存整份发出去」：内存不足 `limit` 条时，从转录取内存最早那条（压缩过的会话就是摘要块）之前的一页，把首屏**填满到 `limit` 条**——压缩之前的历史就这样直接出现在首屏，用户不必先上翻才能看到它。填报的规矩与上翻一致：按 id 去重（压缩保留的轮次在转录里排在摘要块**之前**，与内存尾部重复，重复的丢掉）、页首仍必须带 id、补进来的部分**不写回** SDK 的列表（内存口径不变，见下一个故事）。锚点在转录里找不到、或读盘失败时退回「内存这一页 + 还有更早」，留给下一次上翻再试——首屏取数**任何时候都不因读盘失败而回空表**。

**首屏读盘的代价有界**：倒读要先在转录里定位内存最早那条（压缩过的会话就是摘要块），再往前读满一页；两段分别与「摘要块之后追加了多少」和一页的量同阶，不是整份文件。

**页首必须带 id**：一页的**第一条**是下次上翻的锚点，所以收满条数后如果最早那条没有 id，继续往前收，直到出现带 id 的一行为止——一页因此可能比 `limit` 多几条，换来的是翻到任何一页都接得上。页尾（最新那条）不受这条约束。

**读盘期间会话换了 ⇒ 这一页作废**：读页是异步的，期间可能发生「开新会话」（换 `sessionId`）或恢复另一条会话，那一页就属于上一条会话，拼进新列表等于串会话。判据是「读完再比一次会话身份」：变了按新路径重读一次；重读期间又在变，就按**锚点丢失**处理（宿主换一个更早的已知 id 重试），而不是回一个「没有更早」。参考物在同一位置做同样的 revalidation（`getSession`／`clearGeneration`／转录路径三项比对，失败即弃用这一页）。

**独立测试**：给一条 250 条消息的合成会话绑定列表，断言首屏只取一页且回包带「还有更早」；把列表滚到顶部，断言发出一次取更早请求、新页接在已加载内容之前且视口仍停在原来那一行；再滚到顶，断言收到「没有更早」后顶部出现终点、不再有请求；断言这几次翻页都没有改变 SDK 的消息列表长度。另造一条压缩过、压缩后只追加了几条的会话，断言首屏那一页仍按条数填满到 `limit`、压缩前的消息就在首屏里、且 SDK 列表长度不变。

**验收场景**：

1. **假设** 会话有 N 条显示消息，**当** 页面绑定它（冷启动 / 切会话 / 重连后重推）时，**则** 首屏只下发最近一页（按条数收，`200` 条封顶；内存里不足一页时从转录补齐到 `200` 条），回包带「还有没有更早」；页面先把这一页渲染出来，不等更早的部分。
2. **假设** 用户把列表滚到顶部并继续上拉，**当** 到底时，**则** 自动取更早一页（不需要点按钮，也不弹提示条），取回的页接在已加载内容之前。
3. **假设** 更早的一页刚被接上，**当** 那一刻视口重排时，**则** 用户刚才看的那一行仍在原处（不跳到新页开头，也不跳到底部），滚动位置随内容增长等量右移。锚点**不得**取「视口顶落在新一页最后一行里的第几 px」——那一行此刻只有估算高度，它一量出来，误差会整块漏在屏幕上（实测：早一页尾巴是 120 行代码块时位移 1046px）；锚点要落在视口里**已有实测高度**的行的起点上。
4. **假设** 回包说「没有更早了」，**当** 用户再次上拉到顶时，**则** 顶部就是这条会话的第一条消息，页面不再发请求、也不渲染加载占位或空白占位行。
5. **假设** 上次那一页还没回来，**当** 用户又滚到顶时，**则** 不叠加第二个取更早请求（同一时刻只有一个在飞）；请求失败时已渲染内容不动，给出一次重试机会，且**失败不得被当成「没有更早」**——否则一次网络或读盘抖动就会让这段历史再也翻不出来。
6. **假设** 首屏那一页没有填满视口（窗口很高，或整条会话不足一页），**当** 首屏渲染完成时，**则** 继续往前取直到填满或确实没有更早；这段连追**必须有次数上限**，且不改变第 3 条的锚点规则（视口仍以用户看到的底部为准，页一接上来就被推下去不算锚点）。
7. **假设** 会话正在流式输出，**当** 新消息到达时，**则** 它们照常追加到列表末尾；用户在翻历史（视口已离开底部）期间，新消息不得把视口拉回，钉底跟随的判据与「首开会话钉底」是同一套，不因分页另立一套。
8. **假设** 用户翻过了压缩边界，**当** 更早那一页渲染时，**则** 压缩之前的消息与更早的摘要块都正常显示，不因为是「压缩前」而隐藏（转录里本来就有全量，见下一个故事）。
9. **假设** 追溯用的锚点在转录里已经不在了（转录被重写、回滚截断、会话被替换），**当** 取更早时，**则** 从「已知位置的下一行」重定位后继续取，而不是安静地回一个空页或直接判定到底；确实一条都取不到时才报终点。
10. **假设** 这份列表的第一条不是用户消息（压缩点落在一轮对话的中间，或会话首条就是注入的系统消息），**当** 用户滚到可见的第一条之前的位置时，**则** 吸顶用户消息条为 `null`——列表里本来就没有更上一条用户消息可挂，页面不渲染空条。

***

### 用户故事：内存只装压缩后的上下文，更早的历史按页从转录取（优先级：P1）

作为长期使用同一条会话的用户，我希望 SDK 与宿主手里的消息始终只是「最近一次压缩 + 它之后的消息」这一份，而更早的历史是翻到那里才从转录读出来的，以便会话跑了几个星期、其中 Read / MCP 读过几十张截图之后，常驻内存不随会话长度增长，而历史又没有对我关上。

**为什么是这个优先级**：压缩只折叠了发给模型的上下文，展示流过去仍保留全量，宿主过去那份缓存也被契约要求「必须是全量」（首屏快照、回滚、取完整线程都从它取数）。于是常驻持有量随会话长度线性增长，而消息里的图片是 base64 形态（Read / MCP / Exec 的工具结果）、列表却并不渲染它们——除了「下一轮发给模型」之外没有任何常驻理由。

**口径分两层，别合成一层**：**模型上下文与常驻内存**以压缩为界（SDK 的列表 = 摘要块 + 压缩保留的轮次，宿主对消息内容零状态）；**屏幕上的回看深度**由用户上翻决定，数据从转录按游标按页读——首屏那一页也在这条线上，按条数填满、可以跨过压缩边界（见上一个故事），只是它由系统替用户先翻了第一段。转录是 append-only 的，压缩前的消息一条不少地躺在里面，所以「压缩后不把旧消息装回内存」与「压缩前的历史翻得到」并不矛盾——参考物也是这么分的（分页读本地转录、剪枝只决定模型上下文）。我们这边`getFullMessageThread`（取完整线程、回滚定位）早就是「一次性读转录、读完不留」的形态，分页只是把「一次性全读」换成了「按页读、翻到才算」。

**独立测试**：造一条压缩过、压缩前有 N 条消息（含多张图片）的会话，重载后断言 SDK 列表的条数等于「摘要 + 压缩保留轮次」、不随 N 增长，而首屏那一页是最近一页（压缩后的条数不足一页时从转录补齐，压缩前的消息也在这一页里、且没有重复的保留轮次）；上翻两页后再次断言 SDK 列表长度未变、转录文件未被改写；切走再切回，断言已翻出来的内容整体丢弃、重新从首屏一页开始。

**验收场景**：

1. **假设** 会话压缩过、压缩前有 N 条消息，**当** 宿主重载该会话时，**则** SDK 列表只装「最近一次压缩的摘要块 + 压缩保留的轮次」，持有量不随 N 增长，宿主对消息内容仍零状态；**而首屏那一页按条数填满到 `limit`**，压缩后不足的部分从转录读（压缩前的消息因此就在首屏里，重复的保留轮次按 id 去掉），补进来的部分不进 SDK 列表；从未压缩过的会话首屏同样是最近一页，不是整条线程。
2. **假设** 用户上翻取更早，**当** 一页从转录读回来时，**则** 它**不进** SDK 的消息列表（模型上下文不受影响），宿主也不持有它（宿主对消息内容仍零状态），只进页面侧这份渲染列表。
3. **假设** 用户切换会话、页面重载或重连，**当** 列表重建时，**则** 已翻出来的更早内容整体丢弃，按首屏一页重新开始——回看深度不跨会话累积。
4. **假设** 会话发生多次压缩，**当** 用户往回翻时，**则** 每个压缩点的摘要块都按转录里的先后出现（不只有最后一个），压缩前的消息也都可见；而模型上下文仍只从最后一个摘要块开始。
5. **假设** 页面侧已加载的更早内容涨到需要收敛，**当** 触发回收时，**则** 从离当前视口最远的一端丢，用户正在看的那一段不动（参考物同样把「锚点落在前若干行内」排除在裁剪之外），且锚点行本身永不被回收。
6. **假设** 取更早期间会话里发生了一次压缩（摘要块被追加到尾部），**当** 通知到达时，**则** 它照常进入页面侧列表；这个追加不会移动或作废已经在用的锚点（压缩只追加、不重写转录里已有的行）。
7. **假设** 某个功能需要压缩前的历史（`/rewind` 检查点、会话标题与统计、完整线程导出、回滚定位），**当** 它取数时，**则** 仍从转录一次性读，读完不留成常驻缓存，也不把结果塞进列表。
8. **假设** 取消息时不带 `limit`（三个本机宿主、内部调用），**当** 调用时，**则** 返回当前列表的全部内容——这里是「整份」= 压缩后的上下文，不是「跨压缩边界的全史」。
9. **假设** 页面手上已有一份列表，**当** 它按 `since` 锚点补差（远端页重连），**则** 锚点在这份列表里就回其后的消息；锚点不在（压缩 / 回滚 / 换会话）则回「重新拉尾页」信号，不安静地回一个空页。

**一次性读的路径也别整份进内存**：恢复会话、导出、回滚定位这类「读完即扔」的读，按块流式读（正读倒读同款，只在换行字节切行），内存峰值是它最终要返回的那份数组，不再额外压一份整文件字符串和一份行数组——这条路径不随转录长度多花一倍中间态。**恢复是其中最常走的一条，只装尾部**：最后一个压缩边界之前扫过即丢，同时在同一趟里收齐四件看整条线程的信息（首个用户输入、完整条数、用量表、自定义标题），所以恢复时的持有量 = 压缩后的上下文 + 一份用量表，不随会话变长而涨。**只取字段不求消息的调用方不许读转录**：会话 id 这类信息本来就能从参数拿到，为它读整份转录是把上面这些成本白付一遍。

***

### 用户故事：宿主不保留消息内容，用的时候现取（优先级：P1）

作为一天里翻过几十个会话、并排开着几个对话框的用户，我希望宿主进程里**根本不存消息内容**——消息只在两处存在：跑着这条会话的 CLI 子进程（真相），和正在显示它的那个渲染进程（屏幕那一份）。我浏览过、切走过多少会话，宿主都不因此多留一份。

**为什么是这个优先级**：上一个故事把宿主的持有量按 pane 绑定收敛了，但它是在**自己造的中间层上做记账**：`StdioAgent.messages` 这份镜像要宿主自己 append 每条新增、自己合并每个流式 delta、自己按「末条是不是 assistant」决定错误块挂哪儿、自己判断「这份空数组是因为被丢弃还是因为这条会话本来就是新的」（`droppedMessageCopies`）、切回时先重拉再推快照、还得用一份「池子里还剩多少条」的诊断读数来证明它没在涨。而这份镜像**没有一条独有真相**：消息内容 SDK 恒可复现（一次 `getMessages` pull）、标题与总量来自 `threadSummary`、上下文用量随回包下发、会话存在性看会话索引。VS Code 与 JetBrains 两个宿主其实早已是「快照 + 转发」形态（只写整份替换、压缩摘要块、rewind 截断三处，增量一律只转给界面），桌面端是唯一真正镜像全部增量的那个——这层镜像只在桌面端长大，换来的却是一整套容易写错的记账。参考物那边没有这一层：Claude Code 的 GUI 客户端是连上时从 REPL 拿一份、之后靠事件流，它的 VS Code 插件宿主取历史时直接读转录，谁都不在宿主进程里养一份常驻数组。因此口径定为——**宿主对消息内容零状态，用的时候现取**。

**判据**：宿主记住的只有「状态」——sessionId / workdir / host、流式与命令运行中、待确认、任务、上下文用量、以及「这条会话收到过消息事件」这类轻量标记——**不记消息本身**；SDK 客户端（`StdioAgent`）也不留缓存，一次 pull 拿到的数组用完即弃。

**现取的三条路**：① 首屏 / 切会话 / 窗口重载 → pull 一次，回包直接进快照，宿主不留引用；② 增量 → SDK 的各类消息通知原样转发给渲染进程，宿主不 append、不合并、不截断；③ rewind 与压缩 → 由回执或回包给出新的列表或边界，宿主不靠本地数组定位。

**这几处必须换来源**（否则零状态会把现有行为弄坏）：

* **「是不是新建会话的空脚手架」**：不能再读消息数组长度，改用「这个 agent 是否收到过任何消息事件」的标记 + 会话索引里有没有该 sessionId。判据写错就会把后台活跃会话当脚手架销毁。
* **错误块落位**：宿主现在按「末条是不是 assistant」决定挂上去还是新建一条。改用 SDK 通知直接带完整 message（对齐压缩摘要块 `compactBlockAdded` 的既有做法），或由渲染进程按同一规则自己算（webview 已有对应 reducer）。
* **rewind 边界**：宿主不再有数组可截断，边界由 `rewindToMessage` 的回执给出（或三端一律重拉）。
* **会话标题**：统一读 `threadSummary.firstUserText`，删掉「从数组里翻首条用户消息」的兜底。
* **渲染进程的内存曲线**：宿主不再有可数的数组，「被显示会话的消息量」改由渲染进程上报；「池子里有多少条没有界面在显示」这个读数整体删除——没有池子里的副本了。

**独立测试**：造 A / B 两个会话，把 A 绑到 pane1 收若干条流式帧，切到 B、再切回 A，断言 A 的快照等于 SDK 当前列表（不是上一轮残留）；断言流式期间宿主没有累积任何消息内容（pull 之后没有任何地方再写消息数组）；断言「收到过消息事件、但当前没有消息可数」的会话在释放时不会被当成空脚手架销毁；删掉宿主侧消息数组的字段后整个包能编译（编译期即证明没有地方还在读它）。

**验收场景**：

1. **假设**一个 pane 绑定某会话，**当**快照推给界面时，**则**宿主先 pull 一次整份列表、把回包直接放进快照，之后不保留引用；界面拿到的是该会话当前的全部内容。
2. **假设**消息正在流式产出，**当**消息新增 / 助手消息新增 / 流式文本块 / reasoning 块 / 工具块 / 错误块 / 后台任务通知 / 压缩摘要块这些通知到达，**则**宿主原样转发给渲染进程，自己不留、不合并、不截断。
3. **假设**用户在 A、B 两个会话之间来回切了 N 次，**当**观察宿主进程的持有量时，**则**它不随 N 增长——每次切回都是一次 pull，用完不留。
4. **假设**切回一条后台期间一直在产出的会话，**当**绑定它时，**则**快照就是它此刻的完整列表（含后台期间新增的内容），不是上一轮缓存的残留；pull 失败时维持恢复态、不推空列表。
5. **假设**一条会话收到过消息、但当前没有消息可数（刚被压缩，或通知已转发而列表还没拉过），**当**宿主判断「这条是不是新建会话的空脚手架」时，**则**不得销毁它——判据是「收到过消息事件」与会话索引，不是数组长度。
6. **假设**用户执行 `/rewind`，**当**截断完成时，**则**列表边界来自 SDK 的回执（或重拉结果），宿主不靠本地数组定位锚点，界面显示的条数与 SDK 一致。
7. **假设**工具执行出错，**当**错误块要落位时，**则**它落在正确的那条消息上（与改动前逐字一致），依据是 SDK 通知带出的 message 或渲染进程的同一套规则，而不是宿主手里的末条。
8. **假设**需要知道每条会话占了多少条 / 多少块（内存曲线），**当**读取诊断数据时，**则**这个数来自渲染进程上报；宿主不再有可数的数组，也不再报告「池子里还剩多少条」。
9. **假设**某个宿主自己维护着当前会话的列表（VS Code 的每个聊天面板一份），**当**它取消息时，**则**直接把回包发给界面、不存任何字段；**同一会话在宿主进程里一份数组都不留**——SDK 客户端那次 pull 的数组用完即弃（取代旧的「只有一份数组，且必须是最新那份」）。
10. **假设**宿主不再自己合并流式 delta，**当**界面需要流式块的时间信息（reasoning 块起止）时，**则**以 SDK 通知里下发的为准；没带时按缺失处理，不因缺时间戳而报错或错位。

***

### 用户故事：CLI 消息列表只加载压缩之后（优先级：P1）

作为在终端里长期用同一条会话的用户，我希望 `wave` 的消息列表在恢复与结构性刷新时只装「最近一次压缩的摘要 + 它之后的消息」，而不是这条会话自诞生以来的全部消息，以便会话越跑越久、Read / MCP 读进几十张截图之后，进程里不再挂着一份翻也翻不到的全量列表。

**为什么是这个优先级**：上一个故事约束的是宿主与渲染进程；CLI 是第三端，它把 Agent 跑在同一个进程里，消息列表就是屏幕上那一份，而**屏幕从来没有显示过全量**——渲染上限是最近 `30` 条（折叠态）/ `10` 条（展开态），超出部分直接不渲染；清屏重放（`forceRemount`）同样只放这些条数。也就是说列表里「超出渲染上限的那部分」唯一的读者是重放，而重放并不显示它：这部分纯属持有成本（图片是 base64），换不来任何可见内容。压缩点提供了一个与用户认知一致的边界——屏幕上那时本来就只剩摘要。**三端因此同口径**，CLI 与宿主的差别只在「有没有分页手势」：CLI 没有，它只渲染最近的几十条。

**独立测试**：造一条压缩过、压缩前有 N 条消息（含多张图片）的会话，恢复后断言列表条数 = 压缩摘要 + 压缩时保留的轮次、且不随 N 增长；断言 `/compact` 完成后列表收缩；断言从没压缩过的会话行为与改动前逐字一致。

**验收场景**：

1. **假设** 一条会话压缩过、压缩前有 N 条消息，**当** CLI 恢复该会话（`wave -r` / `/resume` / 冷启动）时，**则** 列表只装最近一次压缩的摘要块及其之后的消息，压缩前的历史不进列表，持有量不随 N 增长。
2. **假设** 会话从没压缩过，**当** 恢复时，**则** 没有可裁的边界，仍装整条线程——行为与改动前逐字相同（这一档不设上界，见下方「已知局限」）。
3. **假设** 用户在会话中执行 `/compact`，**当** 压缩完成时，**则** 列表收缩为摘要 + 压缩时保留的轮次，清屏重放呈现的就是这一份（不再把压缩前的历史重放一遍）。
4. **假设** 压缩之后继续对话，**当** 新消息到达时，**则** 照常追加到列表，不要求重新读盘或重建列表。
5. **假设** 发生结构性刷新（`/clear`、`/rewind`、切会话、退出展开视图），**当** 列表重建时，**则** 仍以最近一次压缩为边界——不得借一次刷新把压缩前的历史装回来。
6. **假设** 某个功能需要压缩前的历史（`/rewind` 检查点、完整线程、回滚定位、会话标题与统计），**当** 它取数时，**则** 从转录一次性读取，与本故事的列表口径无关。
7. **假设** 同一条会话先在 CLI 看、再在 webview 看，**则** 两端的列表口径相同（常驻的都是压缩后的上下文）；差别只在屏幕：CLI 的渲染上限是最近几十条、且没有翻页手势，webview 首屏一页交给虚拟化列表、更早的内容在上翻时才从转录按页读回来。

**已知局限**：边界取「压缩」而不是「条数」，所以一条**从未压缩过**的长会话仍然全量加载；压缩一旦发生（上下文满会自动发生），持有量的上界变成「摘要 + 一个上下文窗口」，不再随会话长度增长。要彻底封顶需要再叠一个「最近 N 条」窗口，与压缩边界并用——本故事不做。

***

### 用户故事：表格展示与滚动（优先级：P1）

作为用户，我希望表格结构清晰、列宽按内容分配、放不下时在表格区域内横向滚动——而不是被压成「一字一列」的竖排，也不是被对话列裁掉一半且滚不到。

**为什么是这个优先级**：表格是 AI 回答里最常见的结构化输出，「能读」是底线；若不解决列宽压缩，可读性会直接塌掉（P1）。

**独立测试**：构造 4 列、8 列、含长路径与长说明的富单元格表，在 1440 / 994 / 480 三档窗口下测列宽、单元格折行与断字、表格滚动量、页面是否被撑宽；统计「折行单元格数」「最窄内容列宽」。另在远控 Web 页手机视口（390×844、粗指针）下测同一张宽表能否滚到末列。

**验收场景**：

1. **假设**表格自然宽度超出消息列，**当**渲染完成，**则**表格在自身区域内横向滚动（由一层滚动包裹层承接），**页面本身不产生横向滚动**；滚到最右时末列内容完整可见。**该滚动能力与宿主无关**：桌面端、VS Code、JetBrains、远控 Web 页（含手机档）都必须给出可滚动的表格区域——对话列本身是 `overflow-x: clip`，包裹层不带滚动即等于把溢出部分静默裁掉（2026-10-21 实测远控页 iPhone 视口下 543px 宽表落在 358px 容器内，右缘 185px 看不见也滚不到）。
2. **假设**表格处于横向滚动状态，**当**键盘用户 Tab 到该区域（桌面端），**则**滚动区可聚焦且焦点位置可见，可用方向键横向翻看完整内容。
3. **假设**表格宽度足够放下，**当**查看列宽，**则**按内容分配：短 token 单元格（分类/状态/序号/数值/日期等短词）保持自身自然宽度、**不被挤成逐字竖排**，长内容列吸收剩余空间；不使用三等分或固定百分比之类把整表推出容器的策略。
4. **假设**单元格内是长且不可断的内容（URL、邮箱、路径、长英文串），**当**列宽不足，**则**才在该单元格内断行（仅在放不下时断），普通说明文字则按词自然折行；**不得对所有内容统一使用「任意位置可断」**——那会把最小内容宽度降到 1 字符，导致浏览器持续从长内容列抽走宽度、把短列压成竖排。
5. **假设**表格单元格文本比同列其它格更长，**当**渲染完成，**则**允许在格内按词折行、不得裁切或省略文字（不出现 `text-overflow: ellipsis` / 隐藏内容来减少行数）。
6. **假设**表格单元格内出现行内代码或链接，**当**渲染完成，**则**其角色跟随各自归属（代码角色 / 链接角色），列宽与换行策略不因此改变。
7. **假设**同一表格中同时存在「短 token」与「长 token」两类单元格，**当**注入列宽分类标记，**则**同一单元格可同时带「长度分类」与「对齐分类」两类标记，各管各的（断行策略与对齐判定互不冲突）。

***

### 用户故事：表格对齐（优先级：P1）

作为桌面端用户，我希望未声明对齐的表格默认左对齐、数值比较列右对齐、编号与日期等标识列不被误判为数值列，多行说明所在行的短内容贴顶——而不是表头居中、正文左对齐这种同列两种对齐。

**为什么是这个优先级**：表头默认居中是浏览器默认样式与产品样式叠加出的错误默认值，属「一眼可见的不专业」；同时它决定表格是否可被逐列比较阅读（P1）。

**独立测试**：构造未声明对齐的普通表、显式声明左/中/右的对齐演示表、混合表（含单格数字、图标列、操作列、多行说明列），逐列核对表头与单元格的水平对齐，统计「同列表头与正文对齐不一致的列数」（应为 0）；核对单元格垂直对齐。

**验收场景**：

1. **假设**Markdown 表格未声明任何列对齐，**当**渲染完成，**则**表头与单元格**同为左对齐**（不采用浏览器 UA 给表头的居中默认值）。
2. **假设**表格任意一列，**当**查看该列表头与正文的水平对齐，**则**二者一致（使用同一条列级判定，同一列同一个值）。
3. **假设**某列列头命中可比量关键词（数量/个数/条数/耗时/时长/内存/体积/金额/占比/百分比/覆盖率等）**且**该列整列非空单元格都能解析为数值，**当**判定列类型，**则**该列右对齐；容忍千分位、小数、正负号、比较符、货币前缀与单位后缀，`—` / `N/A` / `待定` 视为缺失不影响判定；任一条件不满足即保持左对齐。
4. **假设**某列列头是标识类（编号/序号/号/ID/版本/ver/电话/手机/传真/日期/时间/date/邮箱/端口/卡号/邮编），**当**判定列类型，**则**强制左对齐，**不因整列都是数字就右对齐**。
5. **假设**某列整列非空单元格都是图标（如 ✅/⚠️/❌），或列头明示为操作/动作/action 且整列都是无空白短词（如 查看/编辑/重试），**当**判定列类型，**则**该列居中；**只有**这两种情形才居中。
6. **假设**列类型判定，**当**实现，**则**以**整列**为单位：不按列序、不按表结构、不按具体表格内容，也不因为某一个单元格是数字就推断整列是数值列。
7. **假设**表中存在多行正文单元格撑高整行，**当**查看同行其它短内容，**则**垂直**顶部对齐**（不再被垂直居中到行中间）。
8. **假设**Markdown 显式声明了列对齐（`| :--- | :--: | ---: |`），**当**渲染完成，**则**保留显式声明（左/中/右各自生效），不被全局默认规则覆盖；**AI 生成的表格内容、行列与对齐声明零改写**，本规格只约束渲染默认值。

***

### 用户故事：助手消息代码块的语法高亮（优先级：P1）

作为用户，我希望助手消息里的围栏代码块按语言着色（关键字、字符串、注释各自有配色），而不是整块单色等宽文本，以便在长回答里一眼分出代码结构、字符串与注释。

**为什么是这个优先级**：agent 的回答大量用围栏代码块承载示例代码、命令、配置与 diff，代码块是对话流里唯一「内容有结构却只能整块读」的区域；同一套高亮能力与配色文件面板早已具备（`utils/highlight.ts` + FilePane.css 的调色板），消息侧缺失是能力不一致（P1）。

**独立测试**：渲染带语言标记的代码块（`ts` / `python` / `json` / `bash` / `diff`），读 DOM 核对 token 元素与语言类名，并核对块内纯文本与代码原文逐字节一致；渲染无语言标记、未知语言标记的代码块，核对仍是纯文本；渲染含裸 URL 与文件路径的代码块，核对链接照常生成且点击目标不变；渲染 mermaid 代码块，核对仍为图表。

**验收场景**：

1. **假设**围栏代码块的语言标记是 webview 已注册的语言或其别名（`ts` / `tsx` / `js` / `py` / `yml` / `sh` / `html` / `diff` 等），**当**渲染完成，**则**按该语言着色，且块内**纯文本与代码原文逐字节一致**——着色只加行内元素，不改写/不增删任何字符、换行与缩进，语言类名照常保留（`class` 里同时带语言类名与高亮标记）。
2. **假设**代码块**没有**语言标记，**或**标记的语言未注册（如 `lua` / `tex`），**当**渲染完成，**则**不猜测语言、按纯文本渲染（与高亮上线前一致），不报错也不退化成别的东西。
3. **假设**代码块内容含裸 http(s) URL 或文件路径，**当**渲染完成，**则**链接仍按 [markdown 链接](markdown-links.md) / [文件路径链接](file-path-links.md) 的规则生成、点击目标不变——**着色不得吞掉链接**；两者都不命中时文本完整转义后按原文展示。
4. **假设**代码块是 `mermaid`，**当**渲染完成，**则**仍渲染为图表，不参与高亮。
5. **假设**高亮会产出新的行内标签或类名，**当**内容注入前经净化，**则**这些标签与类名必须同时放行到净化白名单——否则高亮被静默剥掉，屏幕上仍是单色文本，而单测与走查看不出差异（同「键盘可达与无障碍」里那条白名单约束）。
6. **假设**语言标记可识别但高亮过程抛错，**当**渲染，**则**回退为纯文本渲染：高亮是锦上添花，代码内容必须可见。
7. **配色与生效范围**：消息代码块与文件面板**共用同一份调色板**（不另立第二套），代码块背景与正文色仍取宿主主题变量；**四个 GUI 宿主（桌面端 / VS Code / JetBrains / 远控 Web 页）都生效**，CLI 终端不在本规格范围。
8. **假设**消息很多或代码块很长，**当**渲染，**则**高亮不改变代码块自身的几何与交互：仍是一块可横向滚动的 `pre`、虚拟窗口的行高估算与滚动行为、桌面端键盘可达性均与高亮前一致（见本规格「键盘可达与无障碍」）。

***

### 用户故事：Bash 工具命令行的语法高亮（优先级：P2）

作为用户，我希望消息里的 Bash 命令按 shell 语法着色（可执行文件名、选项、字符串、变量、注释各自有配色），而不是单色等宽的一行命令，以便在一长串参数与管道里一眼分出命令结构，而不是逐个字符读。

**为什么是这个优先级**：命令行是对话流里出现频率最高的代码形态之一（AI 调用的 Bash 工具、`!` 前缀的 bash 模式都落在同一个块上），而它现在与高亮上线前的围栏代码块同病——有结构却只能整块读。能力早已具备（`utils/highlight.ts` 注册了 `bash`，配色见 `styles/code-highlight.css`），缺的只是把这份能力接到命令行上，因此是低成本的一致性补齐（P2：不影响功能可用性，只影响可读性）。

**独立测试**：渲染 AI 调用的 Bash 工具块与 bash 模式块（含输出 / 只有命令两种形态），读 DOM 核对 token 元素存在且命令原文逐字节一致；渲染含裸 URL 与文件路径的命令，核对**没有**生成链接；渲染超长多行命令，核对块高上限与滚动行为未变；核对净化后只有 `span` 与 `class` 被保留。

**验收场景**：

1. **假设**消息来自 AI 调用的 Bash 工具，**或**来自 bash 模式（`!` 前缀）命令消息，**当**渲染完成，**则**命令行都按 shell 语法着色——两条入口共用同一渲染路径（`renderBashIO`），含输出的 unified 形态与只有命令的形态都生效。
2. **假设**命令内容形如 `python train.py` / `node -e "…"`，**当**渲染，**则**仍按 shell 语法着色，**不**猜测语言、**不**按命令里的可执行文件名切换语法；本产品只有 Bash 工具（无 PowerShell 工具），Windows 宿主也是同一档。
3. **假设**着色完成，**则**命令原文**逐字节一致**：缩进、引号、反斜杠、换行与 Unicode 均不改写、不增删；命令块内通过选区和复制拿到的字符与命令原文相同。着色只加行内元素，不引入额外可见字符。
4. **假设**高亮过程抛错，**或**`highlightCode` 判定该语言不可用（返回 null），**则**回退为纯文本渲染——命令必须始终可见、可选中。
5. **假设**命令里含裸 http(s) URL 或文件路径，**当**渲染完成，**则**这些文本**仍是纯文本**：不生成 `<a>`、点击不路由——**本规格刻意不给命令行加链接化**，与围栏代码块的规则不同。理由：命令里的地址常常是 shell 语法的一部分（引号、管道、重定向的目标），链接化既会切碎高亮结构，也会把「点了没反应」变成意外跳转；命令行只在有输出时才被点击容器包住，无输出形态本就没有路由通道。
6. **假设**命令内容注入前经净化，**则**只放行 `span` 与 `class` 两个标签/属性，其余标签与属性一律剥掉——命令来自模型，等价于不可信输入。
7. **配色与生效范围**：与文件面板、围栏代码块**共用同一份调色板**（`styles/code-highlight.css` 的选择器扩到命令块，不另立第二套主题）；命令块背景与正文色仍取既有宿主主题变量；浅色主题下偏淡是已记录的取舍（同「助手消息代码块的语法高亮」场景 7）。**四个 GUI 宿主（桌面端 / VS Code / JetBrains / 远控 Web 页）都生效**，CLI 终端不在本规格范围。
8. **假设**命令很长（多行 heredoc、长参数列表），**当**渲染，**则**几何与交互不变：仍是 12px 等宽、`pre-wrap`、`max-height` 上限 + 纵向滚动；着色不改变换行位置、行高与滚动行为，也不改变工具块头、状态点与错误块的既有渲染。
9. **假设**部署成本，**则**不新增 hljs 语言包（`bash` 已在注册表内），webview 产物体积不因本改动增长；也不为命令长度另设阈值，与围栏代码块同口径。

***

### 用户故事：Bash 工具输出的终端颜色（优先级：P2）

作为用户，我希望命令输出里自带的终端颜色（`git diff` 的红绿增删、测试报告的通过/失败色、彩色 `ls` / `rg`）在消息里**仍然显示成颜色**，而不是被剥成一份单色文本——颜色是命令作者刻意给出的可读性信息，剥掉之后逐行扫 diff 只能靠 `+` / `-` 去猜。

**为什么是这个优先级**：这是与上游 Claude Code 的最后一处口径差异。CC 从子进程收流、写转录、到发给模型全程不剥 ANSI，只在纯文本出口清理（CC 侧是复制/导出与本地斜杠命令；我们侧的出口清单见场景 8）；而我们的剥离发生在 SDK 收流处（`bashTool.ts`），颜色在任何宿主看到之前就没了，属于「信息在源头被丢弃」。用户已明确要求完全对齐上游（2026-11-06 拍板），因此恢复它：数据侧的动作是「不做事」，成本集中在 UI 侧的解析器（P2：不影响功能可用性，只影响可读性）。

**独立测试**：造含 SGR 颜色序列的输出（单色、多色、跨行、跨 chunk 分片），渲染后读 DOM 核对颜色元素存在、可见文本与「终端里看到的一致」；造含非 SGR 控制序列（光标移动、清屏、OSC 超链接）的输出，核对不产生可见字符；造含裸 URL / 文件路径的带色输出，核对链接仍然生效；核对无色输出的 DOM 与改动前一致。

**验收场景**：

1. **假设**命令输出含 SGR 颜色/样式序列（`ESC[31m`、`ESC[1;32m`、`ESC[38;2;…m`、`ESC[0m` 等），**当**渲染完成，**则**按序列着色，且**可见文本**与在终端里看到的一致——转义序列不显示为任何可见字符，也不增删普通字符。
2. **假设**输出含 SGR 之外的终端控制序列（光标移动/定位、清屏、擦除、进度条重绘）或 OSC（窗口标题、超链接），**当**渲染，**则**一律丢弃：不产生可见字符、不改变块几何、不触发任何外部行为（超链接序列不得变成可点击地址）。
3. **假设**一个转义序列在收流时被 chunk 边界切断（半截 `ESC[3` + 半截 `2m`），**当**渲染，**则**结果仍然正确：着色状态按块累积，不得把半截序列当成文本显示出来。
4. **假设**输出里没有任何转义序列，**或**该命令本就没有颜色，**当**渲染，**则**与改动前逐字节、逐 DOM 结构一致（不引入空 `span`、不改变换行与空白）。
5. **假设**带色输出里含裸 http(s) URL 或文件路径，**当**渲染，**则**链接化照旧生效且与颜色共存：颜色落在文本片段上，链接不得因转义序列而被截断或错位。
6. **假设**着色结果注入前经 DOMPurify 净化，**则**着色所需的标签与属性必须同时放行到白名单——否则颜色被静默剥掉、屏幕上仍是单色，而单测与走查看不出差异（同「助手消息代码块的语法高亮」场景 5）。
7. **模型侧保留原文（本规格的取舍，勿"顺手修"）**：同一条输出随后作为工具结果发给模型时**不剥离**，也不在发送前补一次 `stripAnsi`——完全对齐上游口径。代价是模型上下文里可能出现转义序列（上游同样存在，且被用户报告为噪音），收益是与上游行为一致、且免掉"存一份带色、发一份无色"的双形态。模型本身可以避免制造颜色（`--color=never`、让命令走管道），因此常态噪音很小。
8. **纯文本出口才剥离**：落盘转录保留带色原文（回放、UI 与模型看到的是同一份）；需要纯文本的出口在**出口侧**剥离——本地斜杠命令输出（`<local-command-stdout>`，与上游同口径）、CLI daemon 日志展示、Markdown 表格按可见宽度对齐；将来若给命令输出加复制/导出入口，也在那个出口剥。当前**没有**「复制走一屏转义序列」的路径：bash 输出不在复制范围里。
9. **后台任务的日志文件**保留原文（终端 `tail` 会正常着色）；后台任务面板（消息侧 `/tasks` 对话框、CLI 面板）按同一条渲染通道把转义序列渲染成颜色，不得把 `ESC[31m` 当可见字符显示在屏幕上；`Process error: …` 这类**工具自身**的错误文案不是命令输出，仍在生成处剥离。
10. **生效范围**：四个 GUI 宿主（桌面端 / VS Code / JetBrains / 远控 Web 页）都生效；CLI 终端由 Ink 直接透传 ANSI（已实测：Ink 8 的 `Text` 原样保留转义序列），本规格不定义 CLI 视觉。解析是纯字符串处理，不得改变既有的输出截断上限、`max-height` 滚动与虚拟窗口几何。

***

### 用户故事：助手消息代码块的复制按钮（优先级：P1）

作为用户，我希望助手消息里每个围栏代码块**右上角**有一颗复制按钮，一键把这块代码的**原文**复制走（不带围栏标记、不带语言标记），以便把示例代码或命令直接贴进编辑器、终端——而不是在代码里用鼠标划选，还常常多选或少选一行。

**为什么是这个优先级**：对话里的代码块经常是「一条回复里若干段代码、只要其中一段」，回答越长划选越难。claude.ai 桌面端对每个代码块都给了这颗入口（复制成功回勾、可访问名 `Copy code` / `Copied`、位置在代码块右上角与语言名同一行），我们缺的正是这一层（P1）。

**独立测试**：渲染带语言标记与不带语言标记的围栏代码块，读 DOM 核对每个代码块都有一颗复制按钮、落位在代码块容器右上角；点击后核对剪贴板字符串逐字节等于该块的代码原文（去掉围栏行、语言标记与末尾那一个换行，保留缩进 / 内部空行 / 行尾空格）；核对点击后的对勾反馈与恢复；渲染空代码块，核对没有按钮；核对高亮、块内链接、横向滚动与键盘可达均未改变。

**验收场景**：

1. **假设**助手消息里有一个围栏代码块（带不带语言标记都一样），**当**渲染完成，**则**这个代码块的右上角出现一颗复制按钮（图标按钮，无文字标签），落位在**代码块自己的容器内**（相对定位容器 + 右上角绝对定位），不随 `pre` 的横向滚动而移动，也不占代码块高度。
2. **假设**代码块首行很长、宽度超出容器（出现横向滚动），**当**渲染完成，**则**按钮不遮挡首行文字：首行为按钮让出内边距，滚动时按钮仍在右上角不消失。
3. **假设**宿主环境支持 hover（桌面端 / VS Code / JetBrains / 远控页的鼠标环境），**当**鼠标没有悬停在该代码块上，**则**按钮不可见；**悬停该代码块**时淡入。**假设**设备没有 hover（远控页的手机触控），**则**按钮常显——触控用户没有「悬停」这一步，否则永远拿不到入口。这与消息右上角那组动作按钮同源：动作按钮只在悬停时出现，不常驻。
4. **假设**点击复制，**则**写入剪贴板的是**这个代码块的代码原文**：去掉围栏行与语言标记、去掉末尾那一个换行符，其余逐字节一致——缩进、内部空行、行尾空格、Unicode 字符，以及块内被链接化的 URL / 文件路径都不改变；高亮只影响 DOM 结构，**不得**影响复制出来的字符。
5. **假设**复制成功，**则**被点的那颗按钮图标切换为对勾、可访问名与 tooltip 短暂变为「已复制」，约 1.5 秒后自动恢复为复制图标与「复制代码」；反馈只作用于被点击的那个代码块。
6. **假设**复制失败（`navigator.clipboard` 与旧式 `execCommand` 两条路都不可用），**则**不给出成功反馈。
7. **假设**代码块去掉围栏后没有任何可见字符（空块），**则**不渲染复制按钮。
8. **假设**是用户消息，**则**不涉及本场景（用户消息不走 Markdown 渲染）；推理块与压缩摘要块同样由 Markdown 渲染器产出代码块，**行为与助手正文一致**，不另立规则。
9. **假设**内容注入前经 DOMPurify 净化，**则**按钮所需的标签与属性必须同时放行到白名单——否则按钮被静默剥掉，屏幕上没有入口而单测看不出来（同「助手消息代码块的语法高亮」场景 5 的白名单约束）。
10. **假设**键盘用户在代码块上操作，**则**按钮是原生 `button`，可 Tab 聚焦、回车 / 空格触发，有可访问名；焦点样式沿用本规格「键盘可达与无障碍」那一套，不新增第二套焦点样式。
11. **假设**长会话里代码块很多、虚拟窗口按像素预算渲染，**当**滚动，**则**按钮不改变代码块几何（不参与高度计算），虚拟窗口行高估算与滚动行为与上线前一致。
12. **生效范围**：四个 GUI 宿主（桌面端 / VS Code / JetBrains / 远控 Web 页）都生效；CLI 终端不在本规格范围。

***

### 用户故事：链接角色与下划线时机（优先级：P1）

作为桌面端用户，我希望正文里的描述性链接看起来就是正文、而直接展示的网址/邮箱/电话看起来是可精确辨认的地址，并且链接在鼠标悬停或键盘聚焦时给出下划线提示。

**为什么是这个优先级**：链接是对话流里唯一的交互文本载体，「是不是链接」「能不能点」必须一眼可辨；判据错了会把「联系我们」渲染成地址、或把地址渲染成正文（P1）。

**独立测试**：构造描述性链接（查看预览/参考文档/发送邮件/联系我们/拨打电话）、直显地址（http(s) URL、`mailto:`/`tel:` 串、直接显示的邮箱、直接显示的电话）、日期样文本、代码内路径链接，逐元素核对归属角色与下划线时机；用真实键盘 Tab 路径验证聚焦态命中；核对每个链接的 `href` 逐字节未变。

**验收场景**：

1. **假设**链接的可见文字是描述性短语（如「查看预览」「参考文档」「发送邮件」「联系我们」「拨打电话」），**当**渲染完成，**则**使用正文 UI 角色（与所在正文一致），**不加地址类标记**；点击后按该链接既有规则打开（本规格不改跳转语义）。
2. **假设**链接的可见文字本身就是地址——scheme 地址（`http(s)://` / `//`）、显式 `mailto:` / `tel:` 串、直接显示的邮箱、直接显示的电话号码，**当**渲染完成，**则**归入代码角色，并保留长地址换行能力（不撑破消息区域）。
3. **假设**要判断一条链接是不是「直显地址」，**当**实现判定，**则**判据是**可见文字的含义**而不是有没有 `//`；判定不依赖某一条消息的具体内容。
4. **假设**正文里出现日期样文本（如 `2026-09-10`），**当**判定是否电话，**则**不被误判为电话号码。
5. **假设**链接的可见文字与 `href` 不同源（label 与 href 不一致），**当**渲染完成，**则**角色判定只看可见文字，`href` 原样输出、跳转行为不变。
6. **假设**链接处于静态态，**当**查看其呈现，**则**不显示下划线（仅靠颜色区分）；**假设**鼠标悬停或键盘聚焦，**则**显示下划线并**强化颜色对比**（深色主题下改为提亮以体现同一反馈）。
7. **假设**正文里由纯文本自动链接化的文件路径或代码内的链接，**当**查看其下划线时机，**则**与正文内联链接**同一时机**（静态无、hover / focus 有）；是否下划线不改变其可点击性。

***

### 用户故事：键盘可达与无障碍（优先级：P1）

作为使用键盘或读屏的用户，我希望能聚焦到每一处可滚动区域并看到焦点位置、能知道复选框勾选状态、能通过键盘展开/收起折叠块——而不是「内容能看却摸不到」。

**为什么是这个优先级**：可滚动区域不可聚焦、复选框无可访问名、折叠标题不是按钮，都是 WCAG 层面的硬缺陷（分别对应 2.1.1、4.1.2），且已由 axe 报出 critical/serious（P1）。

**独立测试**：对渲染结果跑 axe（浅深两主题）核对 `label` 与 `scrollable-region-focusable` 归零；用真实键盘 Tab 逐个走到代码块、命令输出、写入预览、表格滚动区、折叠标题，验证可达与可见焦点；对折叠标题用 Enter / Space 验证展开态切换；读 DOM 验证复选框属性。

**验收场景**：

1. **假设**消息中存在可滚动的代码块、bash 命令输出、写入预览、表格滚动区，**当**键盘用户 Tab（桌面端），**则**该区域可聚焦且**焦点位置可见**；插件端不新增该类停靠点（不得出现「看不见焦点」的停靠点）。
2. **假设**用户在代码块或命令输出内聚焦后，**当**按下方向键，**则**可滚动查看完整内容（内容不被静默裁切）。
3. **假设**消息中包含 Markdown 复选框（任务列表），**当**读 DOM，**则**每个复选框都有可访问名（已完成 / 未完成），且保留其勾选状态载体、**不把控件隐藏或从无障碍树中移除**。
4. **假设**消息中存在可折叠块（推理过程、压缩工具行等），**当**查看元素，**则**折叠标题是按钮（含展开态属性），键盘可达，Enter / Space 均可切换展开态并同步更新展开态属性；旧宿主不因此退化为浏览器默认按钮。
5. **假设**新增任一元素属性依赖 HTML 净化白名单（可访问名属性、tab 停靠点属性、表格包裹层元素与类名），**当**实现，**则**必须同时放行到净化白名单——否则属性/包裹层会被静默剥掉，修复到不了 DOM 而单测与走查却看不出差异。

***

### 用户故事：桌面状态指示的语义色（优先级：P2）

作为桌面端用户，我希望「进行中 / 已完成 / 失败 / 待确认 / 空闲」这类状态指示在浅色与深色主题下各自都可辨，而不是深色下沿用浅色取值、糊成一片。

**为什么是这个优先级**：状态指示不是排版主线，但同一状态的取值若只在一个主题下定义、或缺一档，对应主题会静默回退到不可辨的取值（P2）。

**独立测试**：在浅色与深色主题下渲染带运行中/已完成/失败/空闲状态的任务列表状态点（含行图标）与时间线节点，核对两主题下各自可辨；并断言每一处引用的状态语义在浅色与深色下都有定义。

**验收场景**：

1. **假设**界面处于浅色或深色主题，**当**渲染任务列表状态点与行图标、工具状态文字/图标、时间线节点，**则**取值来自同一套状态语义（进行中 / 已完成 / 失败 / 待确认 / 空闲），**不在这几处各写各的**。
2. **假设**查看桌面状态语义层，**当**核对取值，**则**浅色与深色**各自取值**：深色下**不沿用浅色取值**，使每种状态在两主题下都可辨。
3. **假设**某处引用某个状态语义，**当**该语义只在单一主题下定义或缺失，**则**属于缺陷（应有守卫测试覆盖引用集合与定义集合的一致性），避免退化为「某一主题下静默取到不可辨的取值」。
4. **假设**插件端不定义桌面状态语义，**当**在 IDE 中渲染同一状态指示，**则**回退到既有取值，不回归。

***

## 边界情况

* **消息超过 10 条怎么办？** 系统应该只渲染最后 10 条消息，以确保终端保持响应且回滚不会变得过多。
* **`forceStatic` 为 true 或视图被展开怎么办？** 所有块，包括最后一条消息中的块，都应该渲染为静态内容，禁用任何动态更新或加载指示器。这确保视图在展开时保持"冻结"和高性能。
* **块类型未知怎么办？** 系统应该优雅地处理，通过忽略它或显示通用占位符，以防止整个 UI 崩溃。
* **插件端差异预览中超大改动怎么办？** 未变更上下文折叠展示，预览区域限制最大高度并可滚动，避免长 diff 撑开对话流。
* **插件端工具参数解析失败怎么办？** 差异/写入预览优雅降级：差异预览不渲染，写入预览仅显示文件标题，不影响消息其余内容。
* **测量缓存与折叠状态不符怎么办？** 缓存条目按折叠状态校验，状态不匹配（如展开后重载、默认折叠）时视为失效，回退到平均值直到该行重新测量。
* **常驻持有量都以压缩为界**：SDK 侧列表等于「最近一次压缩 + 它之后的消息」，宿主侧一份都不留（见下一条），不另存一份跨压缩边界的全史。上翻翻出来的更早内容只活在**页面这一侧的渲染列表**里，进程一重建（切会话 / 重载 / 重连）就没了，不算常驻。「取完整线程」「回滚定位」「会话标题与统计」这类需要全量语义的路径改为一次性读转录，且**不得**把读出来的结果留成新的常驻缓存、也不塞回列表。
* **宿主对消息内容零状态**：宿主只记状态——sessionId / workdir / host、流式与命令运行中、待确认、任务、上下文用量、「这条会话收到过消息事件」——消息内容只在 CLI 子进程（真相）与渲染进程（屏幕那一份）各一份。首屏与切会话现 pull、回包直接进快照；增量通知原样转发；rewind 边界看回执。**不做**在宿主侧做「丢弃 / 记脏 / 切回重拉」那套记账：那是在自造的镜像上打补丁，判据写错就会把后台活跃会话当空脚手架销毁，或者把另一条会话的列表冲空。**也不在本规格里卸载 SDK 侧的空闲会话**：那要走「空闲超时销毁 + 切回恢复」，而销毁会中断进行中的轮次，阈值与红线需单独定。
* **同一会话在宿主进程里一份数组都没有**：宿主取数用一次性查询、回包直接发给界面，SDK 客户端（`StdioAgent`）也不留缓存——两份都留会占内存，且增量只更新其中一份、另一份就是过期数据。**VS Code 关面板的既有语义不变**：关闭面板即销毁会话、中断进行中的轮次（与桌面端「关 pane 后会话在后台继续跑」不同）。
* **宿主自造的那几行提示活不过一次重拉**：桌面端在动作失败时（新建失败、回滚失败、认证提示）往聊天流里插一行本地提示，它不属于会话线程、也不在任何一侧的真相里，所以只存在于渲染进程那份列表上——切走再切回、或任何一次整份推送之后就不见了。这是零状态的直接结果（宿主没有可写的地方），也是可接受的代价：它是关于刚刚那次操作的临时通知，不是历史。要让它持久，就该让它进入会话线程（由 SDK 产生），而不是在宿主侧补一份缓存。
* **跨压缩边界的回看靠转录分页**：压缩前的消息不是靠「留在内存里」可见，而是靠「从转录按页读回来」可见——首屏不含它们，用户上翻时按页读、接在列表前面。锚点是转录里的消息 id（不是数组下标），所以能穿过压缩边界连续翻；转录被重写 / 回滚截断导致锚点失效时**重定位**，确实取不到才报终点。这条路径**不改写转录**、不改变模型上下文、也不回填 SDK 的列表。
* **CLI 不做分页**：终端自身的滚动缓冲承担回看，`wave` 只渲染最近几十条（折叠 30 / 展开 10），列表口径仍是压缩后的上下文——本规格的「上翻取更早」只发生在 webview 四端（桌面端 / VS Code / JetBrains / 远控页，远控页自 2026-11-06 起，见 [remote/remote-web.md](../remote/remote-web.md)「手机上往上翻更早的消息」）。
* **远控页的首屏上限是自己的取值，上翻与其余三端同一条协议**：页面首连 / 切会话取最近 `60` 条（`remote/shim.js` 里的镜像常量，注释指回这里的口径），因为那一侧隔着一层中继——每订阅者缓冲 1 MiB，超限的帧**被静默丢弃**，而实测约 5.7 KB/条，60 条约 0.34 MB（取 `100` 时约 0.57 MB，常态就会越过 512 KiB 预算被裁，页长忽多忽少）。**上翻自 2026-11-06 起也有**：同一套 `loadEarlierMessages` / `earlierMessages`、同一条 `hasMore` / `anchorLost` 判据，一页仍取 `60` 条；宿主侧还有一道 512 KiB 单帧预算兜底（`packages/code/src/remote/frameBudget.ts`）：超预算的页从最早一条开始丢，留下最新的能装下的那段——对「一页更早的历史」而言这恰好是与已加载内容相邻的那一段，所以裁短不漏条，只是页会变短。**裁短时宿主要改回包的 `hasMore`**：裁掉的正是最早的那几条，「更早的还在」必然成立，照抄宿主那句 `false`（上翻取到这条会话最早那一页时就是如此）会让页面把上翻开关关掉，那几条再也翻不回来。**与其余三端的唯一差别在持有一侧**：远控页的宿主（`remote/shim.js`）把翻出来的页也接进自己的影子列表，否则一次结构变化后的整表重发会把已翻出的历史冲掉（那一侧的整表重发比本机宿主频繁：每个结构变化都走一次 `since` 补差后的全量下发）。
* **表格单元格等嵌套层级内的文字**：其呈现不因嵌套深度逐层放大或缩小（表格内的行内代码仍是代码呈现），避免缩到角色档位之外。
* **Mermaid 图表内部文字**不受对话流文字规则约束：其呈现由图形自身布局决定，避免为迁就图形破坏图表布局。
* **代码块高亮的语言覆盖范围**：只覆盖 webview 已注册的语言子集（`utils/highlight.ts`，与文件面板同一份），未注册的语言一律纯文本——这是有意的取舍：注册更多语言要多付页面加载体积，而「认不出的语言仍能读」比「多认几种语言」重要。高亮**不猜语言**（无语言标记的代码块不会自动识别）。
* **高亮配色在浅色主题下未单独调校**：消息代码块沿用文件面板那套取色（面向深色背景的色相），浅色主题下可读但不达对比度标准；这与文件面板现状一致，属同一处待改进项，不在本故事范围。
* **流式输出中的未闭合表格 / 未闭合代码围栏**：Markdown 尚未解析成完整结构，装饰（包裹层、对齐判定等）不生效，不承诺中间态的排版。
* **表格内的极端内容**：表格内嵌 Mermaid、超长无空格 token、超长电话（带分机号）、label 含内联标签（如 `**http://x**`）或 label 与 href 不同源，均按「判定只看可见文字、不猜列类型」的通用规则处理，未逐例验收。
* **窄窗口 + 分屏组合**：994px 分屏下 8 列表的少数列可能拿到较窄剩余宽度并多折几行（短列按自然宽占满后剩余空间有限）；彻底解法需给长内容列设保底宽度，与「优先自然换行、横滚只作兜底」冲突，故不引入。
* **列类型判定无命中时**默认左对齐，不根据单个单元格猜整列类型；标识类列头即使整列为数字也保持左对齐。
* **缩放与文字间距**：编辑器 200% 缩放、WCAG 1.4.12 文字间距未覆盖。
* **插件端真机**：插件端结论以宿主标记代理验证（共用结构变化时外观不变），未做真机走查。
* **状态语义覆盖范围**：桌面状态语义目前只被工具状态文字/图标、任务列表状态点与行图标、时间线节点消费；侧边栏会话树自绘的「等待确认 / 已完成未读」状态点不在此集合内，仍是独立取值（见 [desktop-sessions.md](../desktop/desktop-sessions.md) 的状态点语义）。
* **链接与正文的可区分度**：受下节「已知偏离」约束。
* **动作按钮不另立位置或常显档**：右上角那组（回滚 / 转后台）只有一处——位置、形状、hover 显隐一致，消息流里不常驻任何动作按钮，也就没有为它预留的空白。消息上不显示时间戳。

## 已知偏离（用户明示接受）

**正文内联链接静态态无下划线导致的对比度不达标**：链接与正文文字的颜色对比在浅色主题约 2.9:1、深色主题约 1.99:1，均低于 WCAG 1.4.1 在「仅靠颜色区分」时要求的 3:1。因此自动化无障碍检查会报出 `link-in-text-block`（serious，浅深各 7 个节点）；改为「静态态即显示下划线」时该告警降到 1 个节点。

该项是**用户 2026-09-10 决策的结果**（「常态无下划线，hover / focus 时显示」），**不计为验收失败**。后续若要恢复合规，可选其一：① 回到「静态态显示下划线」；② 提高链接色与正文的对比度至 ≥3:1。除此之外，本规格范围内的自动化无障碍检查（复选框可访问名、可滚动区域键盘可达）应为零违规。

## 相关文档

* 表格与分栏的容器几何、面板宽度：见 [desktop-right-panel.md](../desktop/desktop-right-panel.md)。
* 逐轮走查过程、前后实测数据与截图清单：由设计侧本地台账维护（不入库）；涉及「对话流排版」「表格展示」「表格对齐」「批 1 · 无障碍」的部分结论已落在这里。
