Skip to content

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

创建日期: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「手机上往上翻更早的消息」。

页大小:只按条数,没有字节上限。首屏一页与上翻一页同为 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「工具图片的字节不下发 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 链接 / 文件路径链接 的规则生成、点击目标不变——着色不得吞掉链接;两者都不命中时文本完整转义后按原文展示。
  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「手机上往上翻更早的消息」)。
  • 远控页的首屏上限是自己的取值,上翻与其余三端同一条协议:页面首连 / 切会话取最近 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 的状态点语义)。
  • 链接与正文的可区分度:受下节「已知偏离」约束。
  • 动作按钮不另立位置或常显档:右上角那组(回滚 / 转后台)只有一处——位置、形状、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。
  • 逐轮走查过程、前后实测数据与截图清单:由设计侧本地台账维护(不入库);涉及「对话流排版」「表格展示」「表格对齐」「批 1 · 无障碍」的部分结论已落在这里。