Appearance
功能规格说明:消息渲染系统
创建日期:2026-03-24
桌面端对话流渲染的作用范围(严格区分,勿混用):本规格后半部分(表格展示与滚动、表格对齐、链接角色与下划线时机、键盘可达与无障碍、桌面状态指示语义色)中的视觉与布局规则只在桌面端生效(
[data-host="desktop"]层),插件端(VS Code / JetBrains)保持既有观感;其中的结构与无障碍改进(复选框可访问名、折叠标题改为按钮并带展开态、表格包裹层及其横向滚动、键盘停靠点)在全部宿主(CLI 之外的四个:桌面端、VS Code、JetBrains、远控 Web 页)共用。规格只规定规则与语义,视觉取值(字号、行高、颜色、间距等)不在其内。
用户场景与测试 (必填)
用户故事:消息历史显示(优先级:P1)
作为用户,我希望看到与 agent 的对话历史,以便我可以回顾之前的交互。
为什么是这个优先级:对于基于聊天的 agent 的核心用户体验至关重要。
独立测试:向 MessageList 组件提供消息列表并验证它们按时间顺序渲染。
验收场景:
- 假设有消息列表,当渲染时,则它们按时间顺序显示。
- 假设有较长的消息历史,当渲染时,则仅显示最近的消息以维护性能和可读性。
- 假设有历史消息,当渲染时,则它们使用 Ink 的
Static组件渲染以避免不必要的重新渲染。
用户故事:动态块渲染(优先级:P1)
作为用户,我希望看到活动工具执行或运行命令的实时更新,以便我知道 agent 正在工作。
为什么是这个优先级:在长时间运行的操作期间为用户提供即时反馈。
独立测试:将工具块的阶段从 running 更新为 end 并验证 UI 相应更新。
验收场景:
- 假设一个处于
running阶段的工具块,当渲染时,则它显示动态展示(如加载指示器或进度指示器)。 - 假设一个正在运行的 bash 模式命令(
!ls),当渲染时,则其输出实时更新。 - 假设任何其他块类型(如 text、error),当渲染时,则它被视为静态内容。
用户故事:多种块类型支持(优先级:P1)
作为用户,我希望不同类型的内容(文本、代码、错误、图片、工具调用)被适当地渲染,以便我可以轻松区分它们。
为什么是这个优先级:确保复杂 agent 响应的清晰度和可读性。
独立测试:渲染包含每种块类型之一的消息并验证它们都正确显示。
验收场景:
- 假设一个
stage === "streaming"的文本块,当在折叠模式下渲染时,则它显示最后 30 个字符,灰色并截断(与工具和推理块流式行为一致)。 - 假设一个
stage !== "streaming"(如end或未定义)的文本块,当渲染时,则它使用 Markdown 渲染显示。 - 假设用户消息中的文本块,当渲染时,则它显示完整内容,无论 stage 如何。
- 假设一个错误块,当渲染时,则它以红色显示并带有 "Error:" 前缀。
- 假设一个工具块,当渲染时,则它使用专门的
ToolDisplay组件。 - 假设一个工具块处于
start或streaming阶段,当渲染时,则状态点显示为灰色(执行中、结果未定);处于running阶段时显示黄色;处于end阶段时按结果显示——成功为绿色、失败为红色。 - 假设一个图片块,当渲染时,则它显示图片的占位符或摘要。
用户故事:没有可渲染块的消息不占位(优先级:P2)
作为 WebView 用户,我希望一条还没有任何可渲染内容的消息在列表里完全不占位,以便时间线连线停在最后一个圆点上、列表底部不凭空多出一行空白。
为什么是这个优先级:这类空消息是流式过程的副产物,不是用户要读的内容。codechat 网关上的 Claude 模型思考明文恒为空(只回 signature),流式期第一个 thinking_delta 是空串,SDK 于是在真内容到达前几百毫秒到几秒就先建出一条 blocks: [] 的助手消息。渲染层若照常出外壳,会出现两处偏差:时间线收尾规则(.timeline-row--last 那一段缩到圆点中心)落在一个根本没有 .timeline-row 的行上而失效,前一条真实消息的最后一行保持全高线段——用户看到「一条线一直连到列表底部,底部却没有块」;同时那一行还占着基础行高与自己的 14px 消息间距,底部凭空多出一段空白。
独立测试:构造 blocks: [] 的助手消息、以及「只有空 reasoning 块」的助手消息各一条,断言列表里既没有对应的 .message 元素、也没有为它留的那一行(虚拟行同样不存在);把它夹在同一轮的两条真实助手消息之间,断言连线不断开、收尾落在最后一条真实消息的最后一行上;再把它放到列表末尾,断言底部几何与「没有这条消息」时相同。
验收场景:
- 假设一条消息的块全部渲染不出内容(
blocks为空、空 text / 空 reasoning、只有file_history/task_notification/ 没有图片地址的 image 块),当列表渲染,则它不在渲染列表里:不产生任何 DOM 节点,不占行高与底部间距。 - 假设这样一条空消息夹在同一轮的两条助手消息之间,当列表渲染,则它不参与时间线 run 的起止判定:连线不断开,run 的起点与收尾落在真正带圆点的消息上。
- 假设空消息是一轮的末条(流式刚开始的那一刻),当列表渲染,则收尾规则落在前一条真实消息上,列表底部既不出现空白行,也不出现悬空的线段。
- 假设消息里带 notice 通知块(如 hook 的循环进度行),当判定是否为空,则不算空——通知行照常渲染,不能连通知一起吞掉。
用户故事:插件端 Edit 差异预览(优先级:P1)
前 3 个故事描述 CLI 终端渲染;本故事描述插件端侧的工具结果展示。作为插件端用户,我希望在 AI 修改文件后直接在对话中看到修改前后的差异对比,以便快速审阅 AI 的改动。
为什么是这个优先级:差异审阅是用户判断 AI 改动是否正确的主要依据,直接影响对改动的信任与采纳。
独立测试:让 AI 使用编辑工具修改文件,验证对话中出现红绿差异视图;在编辑工具的权限确认出现时,验证确认框中同样展示该差异预览。
验收场景:
- 假设 AI 完成了一次文件编辑,当工具结果渲染时,则显示差异对比:删除的内容以红色标识、新增的内容以绿色标识、未变更的上下文以灰色标识。
- 假设改动在文件中的位置较集中,当差异渲染时,则未变更的长上下文自动折叠,仅保留变更前后各约 3 行并以省略号标示省略部分。
- 假设改动内容较长,当差异渲染时,则预览区域限制最大高度并可滚动查看。
- 假设编辑工具正在流式输出参数,当结果尚未完成时,则不渲染差异预览,避免展示不完整内容。
- 假设编辑工具请求用户权限确认,当确认框出现时,则确认框内展示同样的差异预览,用户审阅后再决定是否批准。
- 假设编辑前后内容完全一致,当差异渲染时,则显示"No changes"空态。
用户故事:插件端 Write 文件预览(优先级:P2)
作为插件端用户,我希望在 AI 写入文件后看到写入内容的折叠预览与结果摘要,并能一键在编辑器中打开该文件,以便快速核对与跳转。
为什么是这个优先级:写入预览提升可核对性,但写入结果摘要已能提供基本信息,故次于差异预览。
独立测试:让 AI 使用写入工具创建文件,验证对话中出现文件标题、结果摘要与内容预览;点击文件名或打开按钮,验证在编辑器中打开该文件。
验收场景:
- 假设 AI 完成了一次文件写入,当工具结果渲染时,则显示文件标题(工具名与相对路径)与结果摘要(如写入行数)。
- 假设写入成功,当预览渲染时,则显示写入内容的折叠预览,底部以渐变淡出提示内容被截断。
- 假设用户点击文件路径或打开按钮,当点击发生时,则在编辑器中打开对应文件。
- 假设写入工具正在流式输出参数,当结果尚未完成时,则仅显示文件标题,不渲染内容预览。
- 假设写入参数无法解析,当渲染时,则仅显示文件标题,不渲染内容预览。
用户故事:工具文件路径相对化显示(优先级:P1)
作为 WebView 用户(VS Code/JetBrains/桌面端),我希望 read、edit、write 工具标题中的文件路径在 agent 工作目录(workdir)内时显示为相对路径,不在 workdir 内时才显示绝对路径,以便标题简短易读,且与终端(CLI)中的显示保持一致。
为什么是这个优先级:文件工具是 agent 最频繁的操作之一,深目录下的绝对路径会占据大量标题空间,且与用户熟悉的 CLI 行为不一致(同一会话中 CLI 与插件端显示不同路径会让用户困惑)。现有实现仅对正斜杠路径生效,Windows 下(workdir 与文件路径为反斜杠)前缀匹配失败,实际显示为绝对路径,与 CLI 相悖。
独立测试:渲染包含 read/edit/write 工具块的消息,验证标题中的路径按 workdir 相对化显示(在 workdir 内为相对、之外为绝对),并验证点击路径仍以原始路径打开文件。
验收场景:
- 假设 文件位于 workdir 内(如 workdir=
/home/user/repo、文件=/home/user/repo/src/index.ts),当 工具标题渲染时,则 显示相对路径src/index.ts。 - 假设 文件位于 workdir 之外,当 工具标题渲染时,则 显示绝对路径。
- 假设 workdir 与文件路径的分隔符风格不一致(Windows 反斜杠与正斜杠混合),当 工具标题渲染时,则 仍正确显示相对路径,与 CLI 行为一致。
- 假设 文件路径与 workdir 相同,当 工具标题渲染时,则 显示
.,与 CLI 行为一致。 - 假设 workdir 未知(宿主尚未提供),当 工具标题渲染时,则 显示原始路径。
- 假设 用户点击工具标题中的路径,当 点击发生时,则 仍以原始(绝对)路径打开文件,相对化仅影响显示。
用户故事:虚拟窗口按像素预算(优先级: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 空白),否则说明这条轨迹根本没逼近底部、测的不是这件事。
验收场景:
- 假设 会话有数千条消息,当 列表渲染时,则 只有视口附近的若干屏挂在 DOM 上,两侧预留按像素预算计算(与行高无关),视口内不出现未渲染的空白。
- 假设 用户把滚动条拖到任意位置,当 落点确定时,则 该位置立即有内容渲染,不需要等逐行测量从估计值收敛。
- 假设 视口进入末尾两屏以内,当 列表计算渲染范围时,则 一直渲到末行(末尾不做虚拟裁剪),避免末尾的测量修正反复把新行挪进视口。
- 假设 某行还没有被测量过,当 列表估算它的高度时,则 按内容类型给初值(用户行、助手文本按字数与换行折算、工具调用、图片、推理块、压缩提示各一档),而不是一律同一高度;测得真实高度后覆盖估计值,总高度收敛过程中视口不跳。
- 假设 行被测过又滚出渲染范围,当 它再次进入范围时,则 沿用上次的实测高度,不重新从估计值开始。
- 假设 正文列宽随宿主而变(桌面端 800px 列、远控 Web 页 390px 手机列),当 估算纯文字行的高度时,则 折行按正文列的实际宽度计算并按段落分别折行——每行可容纳的视觉字符数由「正文宽度 ÷ 单个视觉字符宽」得出(中文/全角算两格、ASCII 算一格),不得用按桌面宽度写死的固定字数,也不得把整条内容的字符总数一次性折算(那会吞掉每个段落的折行余量);正文宽度变化时,未挂载行的估算按新宽度重算,已实测的行仍沿用实测值。
- 假设 某行的高度从估算值被实测值覆盖(或上方接上更早的一页)、账本随之变化,当 该变化落地时,则 视口补偿必须在同一帧、绘制之前生效(用户看不到未补偿的中间态);不得指望浏览器滚动锚定兜底:行是绝对定位的,页面上没有在流内、位置会变的锚点,实测同一场景下把
overflow-anchor在auto与none之间切换、同时关掉本实现的补偿,可见位移完全一致(每次 71px),这条通路在本架构里补不了任何东西。 - 假设 触控手势正在进行(手指按着,或已抬起但惯性还在滚),当 账本变化需要补偿时,则 不得写
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 条会话后切回最早那条,断言位置已被丢弃、回落到默认呈现。
验收场景:
- 假设 用户在会话 A 里滚到中段阅读,当 切到会话 B 再切回 A 时,则 视口恢复到离开时那一行(行首定位 + 行内偏移),不回到底部。
- 假设 用户在会话 A 里停在底部(跟随状态),当 切走再切回时,则 仍在底部并继续跟随(新消息可见),不恢复成「已离开底部」。
- 假设 离开时锚定的那一行不在切回后的首屏一页里,当 列表恢复时,则 按首屏默认位置呈现,不为了恢复位置去重读更早的页。
- 假设 同时记住的位置超过 32 条会话,当 又有一条新位置写入时,则 按最近使用淘汰最旧,不无上限增长。
- 假设 恢复已经发生过,当 用户随后自己滚动、或新消息到达时,则 以用户动作为准,恢复不得再次写视口。
用户故事:首开会话钉底——测量增长方向(优先级:P1)
作为 WebView 用户(VS Code/JetBrains/桌面端),我希望首次打开一条会话时视口就在真实底部,以便最后一条消息完整可见,而不是停在按内容估出来的高度上、底下留着数百到数千像素的空白。
为什么是这个优先级:高度估算再按内容分档也估不准(图片、长工具输出差得最多),挂载窗口的实测会持续改变总高度;若钉底追逐只在固定帧数内进行,测量增长还没结束追逐就放弃了,视口停在旧总高上且没有后续恢复机制(实测偏移 578~2198px)。所以约定改为由几何变化驱动:用户处于跟随状态时,任何一次总高度变化(行测量回来、容器尺寸变化)之后都重新贴到真实底部,不设帧数预算;反过来,用户一旦用真实输入离开底部,这些重钉一次也不得执行。这里必须分清「这次滚动是谁引起的」:程序写入的视口位置、浏览器自身的 clamp、虚拟窗口因行高修正产生的补偿都会派发 scroll 事件,它们都不代表用户意图。
独立测试:加载行高显著高于估计值的合成会话(如 300 条约 700px 高的消息),初始加载完成后滚动容器 scrollHeight - scrollTop - clientHeight ≤ 2px,且钉底过程跨越 spacer 持续增长的阶段;流式持续期间点击吸顶用户消息条,跳转后目标消息保持居中、视口不被后续重钉拉回;用户停在距底数百像素(如 200px)处时,该距离不得被判为「在底部」——否则任意一次非程序滚动事件都会重新吸附并把视口拽回底;列表内放一个自身可纵向滚动的区域(长思考块)并把滚轮用在其上:内层吃掉的那次手势不得移动视口、也不得上锁,内层已到边界而由列表承接的那次滚动必须照常上锁。
验收场景:
- 假设 会话首次打开(还没有任何一行被测过),当 初始加载完成时,则 视口钉在真实底部(
scrollHeight - scrollTop - clientHeight ≤ 2),即使挂载窗口测量导致总高度持续增长。 - 假设 钉底跟随期间(含流式/思考内容持续增长、每块更新都触发程序重钉),当 用户做出任何主动的滚动动作(滚轮/触控板向上、触摸滑动、键盘滚动键、拖动滚动条)时,则 立即暂停重钉、不把用户拉回底部——滚动意图在输入事件发生的同步时刻记录,不得只依赖浏览器合成的
scroll事件(合成事件在后续渲染帧派发,密集的流式重钉可能先执行而把视口拉回);没有真实输入来源的scroll事件(程序钉底、虚拟器的尺寸补偿、浏览器 clamp)一律不参与「用户是否离开底部」的判定,既不上锁也不解锁;仅当用户在真实滚动动作中回到真实底部(判定阈值取个位数像素级,与 opencode 的 10px / react-virtuoso 的 4px 同档)才恢复跟随;停在距底数百像素处一律算「已离开底部」,不得被重新吸附。边界:本条约定的下限是「视口已离开底部」——差值在 1 像素以内时,虚拟化实现自身的末端锚定会在行高变化时接管视口(该路径只由几何距离判定,不读滚动意图),亚像素级微推不在保证范围内。 - 假设 总高度因测量/流式持续变化,当 每一次几何变化(行测量回来、容器尺寸变化)落地且用户未上滚时,则 都由该变化驱动重新贴到底部(无帧预算上限),最后一次变化后视口保持在底部。
- 假设 行测出比估计矮(如 30px 用户行 vs 数百像素的估计),当 虚拟窗口因行高修正在一帧内反复写入视口(实测同一 tick 写入 160619 与 160123、相差 500px,事件的最终落点为 160103)时,则 该事件不得被误判为用户上滚(不锁死跟随),后续重钉照常进行。判据必须是「这次事件背后有没有真实输入」,不能是「事件落点是否等于我们写入的位置」——程序滚动产生的
scroll事件在渲染帧之后才派发(实测约 70% 晚于下一帧 rAF),同一帧的多次写入还只反映为一个落点;伪滚动事件不得参与用户意图判定。 - 假设 视口已滚出最近的用户消息、列表顶部显示吸顶用户消息条,当 用户在流式输出期间点击该条,则 视口必须跳到该消息并使其居中并停住,此后每次流式重钉都不得把视口拉回底部——该点击与上滚同等视为用户「离开底部」的意图;直到用户主动滚回真实底部(判定阈值同上,个位数像素级)才恢复跟随。
- 假设 用户已上滚离开底部、正在阅读历史,当 出现一次显式置底请求(确认弹窗出现、对话面板/会话绑定、rewind 之后、设置保存成功、宿主下发的置底命令)时,则 该请求只在用户仍处于跟随状态时把视口移到底部;用户已离开底部时视口保持不动,显式请求不得覆盖用户的阅读位置。恢复跟随只有两条途径:用户自己滚回真实底部,或用户发送新消息(发消息即视为回到对话末尾)。
- 假设 列表内部存在自身可纵向滚动的区域(工具输出、思考内容、diff 视图、写入预览、被截断的用户内容;按「实际纵向溢出且有
overflow-y」判定,不依赖人工维护的标记名单),当 用户把滚轮/手指/方向键落在这个区域上,则 该手势不算「离开底部」:只要内层真的吃掉了这次滚动,列表既不移动也不上锁,跟随照常进行;当 内层已到自己的边界、这次滚动实际由列表承接时,则 必须照常上锁——嵌套排除只能抑制「输入时刻的同步上锁」,不得连输入本身一起吞掉(吞掉会让列表自己的上滚不被记录,读者随即被下一次重钉拉回底部)。
用户故事:离开底部时的「回到最新」入口(优先级:P1)
作为 WebView 用户(桌面端 / VS Code / JetBrains),我希望在上翻读历史、下方内容还在继续长的时候,消息区底部一直有一个「回到最新」的入口,以便一键回到对话末尾,而不用拖滚动条或一路滚回去。
为什么是这个优先级:钉底跟随在用户上翻时按约定松手,可内容并不会因此停下——读者既不在跟随状态,也看不出下面又多了多少。参考物的做法是在消息区底部居中浮一颗箭头按钮:视口离内容末尾超过 200px 就出现,离得近就收回,点击即回到底部并重新跟随,按钮下方带一层底部渐隐,让「下面还有内容」在视觉上成立。这条与上一条(钉底)是一对:那条保证不打扰读者,这条保证读者想回去时有明确入口。
独立测试:在长会话里把视口停在距底 600px 处,断言「回到最新」按钮出现、且可被 Tab 聚焦;滚到距底 100px 处,断言按钮收回、且不在 Tab 序列里;在距底 600px 处点击按钮,断言视口落到真实底部(scrollHeight - scrollTop - clientHeight ≤ 2)且恢复跟随——随后一个流式 chunk 到达时视口仍在底部;还没有任何消息时断言不渲染该入口。
验收场景:
- 假设 用户把视口停在距内容末尾 200px 以外,当 列表渲染完成时,则 消息区底部居中显示「回到最新」按钮(圆形、半透明背景、向下箭头),位置浮在内容之上、不改变任何一行的高度与位置。
- 假设 按钮正在显示,当 用户滚回距内容末尾 200px 以内时,则 按钮淡出、不再接收点击,也不再占 Tab 序列的位置(键盘 Tab 不会停在隐形的按钮上)。
- 假设 按钮正在显示,当 用户点击它(或聚焦后按 Enter/Space)时,则 视口回到真实底部并恢复跟随;到达底部后按钮自动收回,此后新的流式内容继续跟随。
- 假设 用户正停在底部跟随,当 流式内容继续增长时,则 不出现该按钮,视口照常跟着走。
- 假设 用户上翻停在距底远处、此时又有新内容追加,当 内容增长使距离进一步拉大时,则 按钮的显示状态随之更新——它不能只靠
scroll事件驱动,内容增长本身不派发滚动事件。 - 假设 会话刚打开、还没有任何消息(欢迎页/首屏加载),则 不渲染该入口。
用户故事:首屏一页,上翻按需取更早(优先级: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 列表长度不变。
验收场景:
- 假设 会话有 N 条显示消息,当 页面绑定它(冷启动 / 切会话 / 重连后重推)时,则 首屏只下发最近一页(按条数收,
200条封顶;内存里不足一页时从转录补齐到200条),回包带「还有没有更早」;页面先把这一页渲染出来,不等更早的部分。 - 假设 用户把列表滚到顶部并继续上拉,当 到底时,则 自动取更早一页(不需要点按钮,也不弹提示条),取回的页接在已加载内容之前。
- 假设 更早的一页刚被接上,当 那一刻视口重排时,则 用户刚才看的那一行仍在原处(不跳到新页开头,也不跳到底部),滚动位置随内容增长等量右移。锚点不得取「视口顶落在新一页最后一行里的第几 px」——那一行此刻只有估算高度,它一量出来,误差会整块漏在屏幕上(实测:早一页尾巴是 120 行代码块时位移 1046px);锚点要落在视口里已有实测高度的行的起点上。
- 假设 回包说「没有更早了」,当 用户再次上拉到顶时,则 顶部就是这条会话的第一条消息,页面不再发请求、也不渲染加载占位或空白占位行。
- 假设 上次那一页还没回来,当 用户又滚到顶时,则 不叠加第二个取更早请求(同一时刻只有一个在飞);请求失败时已渲染内容不动,给出一次重试机会,且失败不得被当成「没有更早」——否则一次网络或读盘抖动就会让这段历史再也翻不出来。
- 假设 首屏那一页没有填满视口(窗口很高,或整条会话不足一页),当 首屏渲染完成时,则 继续往前取直到填满或确实没有更早;这段连追必须有次数上限,且不改变第 3 条的锚点规则(视口仍以用户看到的底部为准,页一接上来就被推下去不算锚点)。
- 假设 会话正在流式输出,当 新消息到达时,则 它们照常追加到列表末尾;用户在翻历史(视口已离开底部)期间,新消息不得把视口拉回,钉底跟随的判据与「首开会话钉底」是同一套,不因分页另立一套。
- 假设 用户翻过了压缩边界,当 更早那一页渲染时,则 压缩之前的消息与更早的摘要块都正常显示,不因为是「压缩前」而隐藏(转录里本来就有全量,见下一个故事)。
- 假设 追溯用的锚点在转录里已经不在了(转录被重写、回滚截断、会话被替换),当 取更早时,则 从「已知位置的下一行」重定位后继续取,而不是安静地回一个空页或直接判定到底;确实一条都取不到时才报终点。
- 假设 这份列表的第一条不是用户消息(压缩点落在一轮对话的中间,或会话首条就是注入的系统消息),当 用户滚到可见的第一条之前的位置时,则 吸顶用户消息条为
null——列表里本来就没有更上一条用户消息可挂,页面不渲染空条。
用户故事:内存只装压缩后的上下文,更早的历史按页从转录取(优先级:P1)
作为长期使用同一条会话的用户,我希望 SDK 与宿主手里的消息始终只是「最近一次压缩 + 它之后的消息」这一份,而更早的历史是翻到那里才从转录读出来的,以便会话跑了几个星期、其中 Read / MCP 读过几十张截图之后,常驻内存不随会话长度增长,而历史又没有对我关上。
为什么是这个优先级:压缩只折叠了发给模型的上下文,展示流过去仍保留全量,宿主过去那份缓存也被契约要求「必须是全量」(首屏快照、回滚、取完整线程都从它取数)。于是常驻持有量随会话长度线性增长,而消息里的图片是 base64 形态(Read / MCP / Exec 的工具结果)、列表却并不渲染它们——除了「下一轮发给模型」之外没有任何常驻理由。
口径分两层,别合成一层:模型上下文与常驻内存以压缩为界(SDK 的列表 = 摘要块 + 压缩保留的轮次,宿主对消息内容零状态);屏幕上的回看深度由用户上翻决定,数据从转录按游标按页读——首屏那一页也在这条线上,按条数填满、可以跨过压缩边界(见上一个故事),只是它由系统替用户先翻了第一段。转录是 append-only 的,压缩前的消息一条不少地躺在里面,所以「压缩后不把旧消息装回内存」与「压缩前的历史翻得到」并不矛盾——参考物也是这么分的(分页读本地转录、剪枝只决定模型上下文)。我们这边getFullMessageThread(取完整线程、回滚定位)早就是「一次性读转录、读完不留」的形态,分页只是把「一次性全读」换成了「按页读、翻到才算」。
独立测试:造一条压缩过、压缩前有 N 条消息(含多张图片)的会话,重载后断言 SDK 列表的条数等于「摘要 + 压缩保留轮次」、不随 N 增长,而首屏那一页是最近一页(压缩后的条数不足一页时从转录补齐,压缩前的消息也在这一页里、且没有重复的保留轮次);上翻两页后再次断言 SDK 列表长度未变、转录文件未被改写;切走再切回,断言已翻出来的内容整体丢弃、重新从首屏一页开始。
验收场景:
- 假设 会话压缩过、压缩前有 N 条消息,当 宿主重载该会话时,则 SDK 列表只装「最近一次压缩的摘要块 + 压缩保留的轮次」,持有量不随 N 增长,宿主对消息内容仍零状态;而首屏那一页按条数填满到
limit,压缩后不足的部分从转录读(压缩前的消息因此就在首屏里,重复的保留轮次按 id 去掉),补进来的部分不进 SDK 列表;从未压缩过的会话首屏同样是最近一页,不是整条线程。 - 假设 用户上翻取更早,当 一页从转录读回来时,则 它不进 SDK 的消息列表(模型上下文不受影响),宿主也不持有它(宿主对消息内容仍零状态),只进页面侧这份渲染列表。
- 假设 用户切换会话、页面重载或重连,当 列表重建时,则 已翻出来的更早内容整体丢弃,按首屏一页重新开始——回看深度不跨会话累积。
- 假设 会话发生多次压缩,当 用户往回翻时,则 每个压缩点的摘要块都按转录里的先后出现(不只有最后一个),压缩前的消息也都可见;而模型上下文仍只从最后一个摘要块开始。
- 假设 页面侧已加载的更早内容涨到需要收敛,当 触发回收时,则 从离当前视口最远的一端丢,用户正在看的那一段不动(参考物同样把「锚点落在前若干行内」排除在裁剪之外),且锚点行本身永不被回收。
- 假设 取更早期间会话里发生了一次压缩(摘要块被追加到尾部),当 通知到达时,则 它照常进入页面侧列表;这个追加不会移动或作废已经在用的锚点(压缩只追加、不重写转录里已有的行)。
- 假设 某个功能需要压缩前的历史(
/rewind检查点、会话标题与统计、完整线程导出、回滚定位),当 它取数时,则 仍从转录一次性读,读完不留成常驻缓存,也不把结果塞进列表。 - 假设 取消息时不带
limit(三个本机宿主、内部调用),当 调用时,则 返回当前列表的全部内容——这里是「整份」= 压缩后的上下文,不是「跨压缩边界的全史」。 - 假设 页面手上已有一份列表,当 它按
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 之后没有任何地方再写消息数组);断言「收到过消息事件、但当前没有消息可数」的会话在释放时不会被当成空脚手架销毁;删掉宿主侧消息数组的字段后整个包能编译(编译期即证明没有地方还在读它)。
验收场景:
- 假设一个 pane 绑定某会话,当快照推给界面时,则宿主先 pull 一次整份列表、把回包直接放进快照,之后不保留引用;界面拿到的是该会话当前的全部内容。
- 假设消息正在流式产出,当消息新增 / 助手消息新增 / 流式文本块 / reasoning 块 / 工具块 / 错误块 / 后台任务通知 / 压缩摘要块这些通知到达,则宿主原样转发给渲染进程,自己不留、不合并、不截断。
- 假设用户在 A、B 两个会话之间来回切了 N 次,当观察宿主进程的持有量时,则它不随 N 增长——每次切回都是一次 pull,用完不留。
- 假设切回一条后台期间一直在产出的会话,当绑定它时,则快照就是它此刻的完整列表(含后台期间新增的内容),不是上一轮缓存的残留;pull 失败时维持恢复态、不推空列表。
- 假设一条会话收到过消息、但当前没有消息可数(刚被压缩,或通知已转发而列表还没拉过),当宿主判断「这条是不是新建会话的空脚手架」时,则不得销毁它——判据是「收到过消息事件」与会话索引,不是数组长度。
- 假设用户执行
/rewind,当截断完成时,则列表边界来自 SDK 的回执(或重拉结果),宿主不靠本地数组定位锚点,界面显示的条数与 SDK 一致。 - 假设工具执行出错,当错误块要落位时,则它落在正确的那条消息上(与改动前逐字一致),依据是 SDK 通知带出的 message 或渲染进程的同一套规则,而不是宿主手里的末条。
- 假设需要知道每条会话占了多少条 / 多少块(内存曲线),当读取诊断数据时,则这个数来自渲染进程上报;宿主不再有可数的数组,也不再报告「池子里还剩多少条」。
- 假设某个宿主自己维护着当前会话的列表(VS Code 的每个聊天面板一份),当它取消息时,则直接把回包发给界面、不存任何字段;同一会话在宿主进程里一份数组都不留——SDK 客户端那次 pull 的数组用完即弃(取代旧的「只有一份数组,且必须是最新那份」)。
- 假设宿主不再自己合并流式 delta,当界面需要流式块的时间信息(reasoning 块起止)时,则以 SDK 通知里下发的为准;没带时按缺失处理,不因缺时间戳而报错或错位。
用户故事:CLI 消息列表只加载压缩之后(优先级:P1)
作为在终端里长期用同一条会话的用户,我希望 wave 的消息列表在恢复与结构性刷新时只装「最近一次压缩的摘要 + 它之后的消息」,而不是这条会话自诞生以来的全部消息,以便会话越跑越久、Read / MCP 读进几十张截图之后,进程里不再挂着一份翻也翻不到的全量列表。
为什么是这个优先级:上一个故事约束的是宿主与渲染进程;CLI 是第三端,它把 Agent 跑在同一个进程里,消息列表就是屏幕上那一份,而屏幕从来没有显示过全量——渲染上限是最近 30 条(折叠态)/ 10 条(展开态),超出部分直接不渲染;清屏重放(forceRemount)同样只放这些条数。也就是说列表里「超出渲染上限的那部分」唯一的读者是重放,而重放并不显示它:这部分纯属持有成本(图片是 base64),换不来任何可见内容。压缩点提供了一个与用户认知一致的边界——屏幕上那时本来就只剩摘要。三端因此同口径,CLI 与宿主的差别只在「有没有分页手势」:CLI 没有,它只渲染最近的几十条。
独立测试:造一条压缩过、压缩前有 N 条消息(含多张图片)的会话,恢复后断言列表条数 = 压缩摘要 + 压缩时保留的轮次、且不随 N 增长;断言 /compact 完成后列表收缩;断言从没压缩过的会话行为与改动前逐字一致。
验收场景:
- 假设 一条会话压缩过、压缩前有 N 条消息,当 CLI 恢复该会话(
wave -r//resume/ 冷启动)时,则 列表只装最近一次压缩的摘要块及其之后的消息,压缩前的历史不进列表,持有量不随 N 增长。 - 假设 会话从没压缩过,当 恢复时,则 没有可裁的边界,仍装整条线程——行为与改动前逐字相同(这一档不设上界,见下方「已知局限」)。
- 假设 用户在会话中执行
/compact,当 压缩完成时,则 列表收缩为摘要 + 压缩时保留的轮次,清屏重放呈现的就是这一份(不再把压缩前的历史重放一遍)。 - 假设 压缩之后继续对话,当 新消息到达时,则 照常追加到列表,不要求重新读盘或重建列表。
- 假设 发生结构性刷新(
/clear、/rewind、切会话、退出展开视图),当 列表重建时,则 仍以最近一次压缩为边界——不得借一次刷新把压缩前的历史装回来。 - 假设 某个功能需要压缩前的历史(
/rewind检查点、完整线程、回滚定位、会话标题与统计),当 它取数时,则 从转录一次性读取,与本故事的列表口径无关。 - 假设 同一条会话先在 CLI 看、再在 webview 看,则 两端的列表口径相同(常驻的都是压缩后的上下文);差别只在屏幕:CLI 的渲染上限是最近几十条、且没有翻页手势,webview 首屏一页交给虚拟化列表、更早的内容在上翻时才从转录按页读回来。
已知局限:边界取「压缩」而不是「条数」,所以一条从未压缩过的长会话仍然全量加载;压缩一旦发生(上下文满会自动发生),持有量的上界变成「摘要 + 一个上下文窗口」,不再随会话长度增长。要彻底封顶需要再叠一个「最近 N 条」窗口,与压缩边界并用——本故事不做。
用户故事:表格展示与滚动(优先级:P1)
作为用户,我希望表格结构清晰、列宽按内容分配、放不下时在表格区域内横向滚动——而不是被压成「一字一列」的竖排,也不是被对话列裁掉一半且滚不到。
为什么是这个优先级:表格是 AI 回答里最常见的结构化输出,「能读」是底线;若不解决列宽压缩,可读性会直接塌掉(P1)。
独立测试:构造 4 列、8 列、含长路径与长说明的富单元格表,在 1440 / 994 / 480 三档窗口下测列宽、单元格折行与断字、表格滚动量、页面是否被撑宽;统计「折行单元格数」「最窄内容列宽」。另在远控 Web 页手机视口(390×844、粗指针)下测同一张宽表能否滚到末列。
验收场景:
- 假设表格自然宽度超出消息列,当渲染完成,则表格在自身区域内横向滚动(由一层滚动包裹层承接),页面本身不产生横向滚动;滚到最右时末列内容完整可见。该滚动能力与宿主无关:桌面端、VS Code、JetBrains、远控 Web 页(含手机档)都必须给出可滚动的表格区域——对话列本身是
overflow-x: clip,包裹层不带滚动即等于把溢出部分静默裁掉(2026-10-21 实测远控页 iPhone 视口下 543px 宽表落在 358px 容器内,右缘 185px 看不见也滚不到)。 - 假设表格处于横向滚动状态,当键盘用户 Tab 到该区域(桌面端),则滚动区可聚焦且焦点位置可见,可用方向键横向翻看完整内容。
- 假设表格宽度足够放下,当查看列宽,则按内容分配:短 token 单元格(分类/状态/序号/数值/日期等短词)保持自身自然宽度、不被挤成逐字竖排,长内容列吸收剩余空间;不使用三等分或固定百分比之类把整表推出容器的策略。
- 假设单元格内是长且不可断的内容(URL、邮箱、路径、长英文串),当列宽不足,则才在该单元格内断行(仅在放不下时断),普通说明文字则按词自然折行;不得对所有内容统一使用「任意位置可断」——那会把最小内容宽度降到 1 字符,导致浏览器持续从长内容列抽走宽度、把短列压成竖排。
- 假设表格单元格文本比同列其它格更长,当渲染完成,则允许在格内按词折行、不得裁切或省略文字(不出现
text-overflow: ellipsis/ 隐藏内容来减少行数)。 - 假设表格单元格内出现行内代码或链接,当渲染完成,则其角色跟随各自归属(代码角色 / 链接角色),列宽与换行策略不因此改变。
- 假设同一表格中同时存在「短 token」与「长 token」两类单元格,当注入列宽分类标记,则同一单元格可同时带「长度分类」与「对齐分类」两类标记,各管各的(断行策略与对齐判定互不冲突)。
用户故事:表格对齐(优先级:P1)
作为桌面端用户,我希望未声明对齐的表格默认左对齐、数值比较列右对齐、编号与日期等标识列不被误判为数值列,多行说明所在行的短内容贴顶——而不是表头居中、正文左对齐这种同列两种对齐。
为什么是这个优先级:表头默认居中是浏览器默认样式与产品样式叠加出的错误默认值,属「一眼可见的不专业」;同时它决定表格是否可被逐列比较阅读(P1)。
独立测试:构造未声明对齐的普通表、显式声明左/中/右的对齐演示表、混合表(含单格数字、图标列、操作列、多行说明列),逐列核对表头与单元格的水平对齐,统计「同列表头与正文对齐不一致的列数」(应为 0);核对单元格垂直对齐。
验收场景:
- 假设Markdown 表格未声明任何列对齐,当渲染完成,则表头与单元格同为左对齐(不采用浏览器 UA 给表头的居中默认值)。
- 假设表格任意一列,当查看该列表头与正文的水平对齐,则二者一致(使用同一条列级判定,同一列同一个值)。
- 假设某列列头命中可比量关键词(数量/个数/条数/耗时/时长/内存/体积/金额/占比/百分比/覆盖率等)且该列整列非空单元格都能解析为数值,当判定列类型,则该列右对齐;容忍千分位、小数、正负号、比较符、货币前缀与单位后缀,
—/N/A/待定视为缺失不影响判定;任一条件不满足即保持左对齐。 - 假设某列列头是标识类(编号/序号/号/ID/版本/ver/电话/手机/传真/日期/时间/date/邮箱/端口/卡号/邮编),当判定列类型,则强制左对齐,不因整列都是数字就右对齐。
- 假设某列整列非空单元格都是图标(如 ✅/⚠️/❌),或列头明示为操作/动作/action 且整列都是无空白短词(如 查看/编辑/重试),当判定列类型,则该列居中;只有这两种情形才居中。
- 假设列类型判定,当实现,则以整列为单位:不按列序、不按表结构、不按具体表格内容,也不因为某一个单元格是数字就推断整列是数值列。
- 假设表中存在多行正文单元格撑高整行,当查看同行其它短内容,则垂直顶部对齐(不再被垂直居中到行中间)。
- 假设Markdown 显式声明了列对齐(
| :--- | :--: | ---: |),当渲染完成,则保留显式声明(左/中/右各自生效),不被全局默认规则覆盖;AI 生成的表格内容、行列与对齐声明零改写,本规格只约束渲染默认值。
用户故事:助手消息代码块的语法高亮(优先级:P1)
作为用户,我希望助手消息里的围栏代码块按语言着色(关键字、字符串、注释各自有配色),而不是整块单色等宽文本,以便在长回答里一眼分出代码结构、字符串与注释。
为什么是这个优先级:agent 的回答大量用围栏代码块承载示例代码、命令、配置与 diff,代码块是对话流里唯一「内容有结构却只能整块读」的区域;同一套高亮能力与配色文件面板早已具备(utils/highlight.ts + FilePane.css 的调色板),消息侧缺失是能力不一致(P1)。
独立测试:渲染带语言标记的代码块(ts / python / json / bash / diff),读 DOM 核对 token 元素与语言类名,并核对块内纯文本与代码原文逐字节一致;渲染无语言标记、未知语言标记的代码块,核对仍是纯文本;渲染含裸 URL 与文件路径的代码块,核对链接照常生成且点击目标不变;渲染 mermaid 代码块,核对仍为图表。
验收场景:
- 假设围栏代码块的语言标记是 webview 已注册的语言或其别名(
ts/tsx/js/py/yml/sh/html/diff等),当渲染完成,则按该语言着色,且块内纯文本与代码原文逐字节一致——着色只加行内元素,不改写/不增删任何字符、换行与缩进,语言类名照常保留(class里同时带语言类名与高亮标记)。 - 假设代码块没有语言标记,或标记的语言未注册(如
lua/tex),当渲染完成,则不猜测语言、按纯文本渲染(与高亮上线前一致),不报错也不退化成别的东西。 - 假设代码块内容含裸 http(s) URL 或文件路径,当渲染完成,则链接仍按 markdown 链接 / 文件路径链接 的规则生成、点击目标不变——着色不得吞掉链接;两者都不命中时文本完整转义后按原文展示。
- 假设代码块是
mermaid,当渲染完成,则仍渲染为图表,不参与高亮。 - 假设高亮会产出新的行内标签或类名,当内容注入前经净化,则这些标签与类名必须同时放行到净化白名单——否则高亮被静默剥掉,屏幕上仍是单色文本,而单测与走查看不出差异(同「键盘可达与无障碍」里那条白名单约束)。
- 假设语言标记可识别但高亮过程抛错,当渲染,则回退为纯文本渲染:高亮是锦上添花,代码内容必须可见。
- 配色与生效范围:消息代码块与文件面板共用同一份调色板(不另立第二套),代码块背景与正文色仍取宿主主题变量;四个 GUI 宿主(桌面端 / VS Code / JetBrains / 远控 Web 页)都生效,CLI 终端不在本规格范围。
- 假设消息很多或代码块很长,当渲染,则高亮不改变代码块自身的几何与交互:仍是一块可横向滚动的
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 被保留。
验收场景:
- 假设消息来自 AI 调用的 Bash 工具,或来自 bash 模式(
!前缀)命令消息,当渲染完成,则命令行都按 shell 语法着色——两条入口共用同一渲染路径(renderBashIO),含输出的 unified 形态与只有命令的形态都生效。 - 假设命令内容形如
python train.py/node -e "…",当渲染,则仍按 shell 语法着色,不猜测语言、不按命令里的可执行文件名切换语法;本产品只有 Bash 工具(无 PowerShell 工具),Windows 宿主也是同一档。 - 假设着色完成,则命令原文逐字节一致:缩进、引号、反斜杠、换行与 Unicode 均不改写、不增删;命令块内通过选区和复制拿到的字符与命令原文相同。着色只加行内元素,不引入额外可见字符。
- 假设高亮过程抛错,或
highlightCode判定该语言不可用(返回 null),则回退为纯文本渲染——命令必须始终可见、可选中。 - 假设命令里含裸 http(s) URL 或文件路径,当渲染完成,则这些文本仍是纯文本:不生成
<a>、点击不路由——本规格刻意不给命令行加链接化,与围栏代码块的规则不同。理由:命令里的地址常常是 shell 语法的一部分(引号、管道、重定向的目标),链接化既会切碎高亮结构,也会把「点了没反应」变成意外跳转;命令行只在有输出时才被点击容器包住,无输出形态本就没有路由通道。 - 假设命令内容注入前经净化,则只放行
span与class两个标签/属性,其余标签与属性一律剥掉——命令来自模型,等价于不可信输入。 - 配色与生效范围:与文件面板、围栏代码块共用同一份调色板(
styles/code-highlight.css的选择器扩到命令块,不另立第二套主题);命令块背景与正文色仍取既有宿主主题变量;浅色主题下偏淡是已记录的取舍(同「助手消息代码块的语法高亮」场景 7)。四个 GUI 宿主(桌面端 / VS Code / JetBrains / 远控 Web 页)都生效,CLI 终端不在本规格范围。 - 假设命令很长(多行 heredoc、长参数列表),当渲染,则几何与交互不变:仍是 12px 等宽、
pre-wrap、max-height上限 + 纵向滚动;着色不改变换行位置、行高与滚动行为,也不改变工具块头、状态点与错误块的既有渲染。 - 假设部署成本,则不新增 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 与改动前一致。
验收场景:
- 假设命令输出含 SGR 颜色/样式序列(
ESC[31m、ESC[1;32m、ESC[38;2;…m、ESC[0m等),当渲染完成,则按序列着色,且可见文本与在终端里看到的一致——转义序列不显示为任何可见字符,也不增删普通字符。 - 假设输出含 SGR 之外的终端控制序列(光标移动/定位、清屏、擦除、进度条重绘)或 OSC(窗口标题、超链接),当渲染,则一律丢弃:不产生可见字符、不改变块几何、不触发任何外部行为(超链接序列不得变成可点击地址)。
- 假设一个转义序列在收流时被 chunk 边界切断(半截
ESC[3+ 半截2m),当渲染,则结果仍然正确:着色状态按块累积,不得把半截序列当成文本显示出来。 - 假设输出里没有任何转义序列,或该命令本就没有颜色,当渲染,则与改动前逐字节、逐 DOM 结构一致(不引入空
span、不改变换行与空白)。 - 假设带色输出里含裸 http(s) URL 或文件路径,当渲染,则链接化照旧生效且与颜色共存:颜色落在文本片段上,链接不得因转义序列而被截断或错位。
- 假设着色结果注入前经 DOMPurify 净化,则着色所需的标签与属性必须同时放行到白名单——否则颜色被静默剥掉、屏幕上仍是单色,而单测与走查看不出差异(同「助手消息代码块的语法高亮」场景 5)。
- 模型侧保留原文(本规格的取舍,勿"顺手修"):同一条输出随后作为工具结果发给模型时不剥离,也不在发送前补一次
stripAnsi——完全对齐上游口径。代价是模型上下文里可能出现转义序列(上游同样存在,且被用户报告为噪音),收益是与上游行为一致、且免掉"存一份带色、发一份无色"的双形态。模型本身可以避免制造颜色(--color=never、让命令走管道),因此常态噪音很小。 - 纯文本出口才剥离:落盘转录保留带色原文(回放、UI 与模型看到的是同一份);需要纯文本的出口在出口侧剥离——本地斜杠命令输出(
<local-command-stdout>,与上游同口径)、CLI daemon 日志展示、Markdown 表格按可见宽度对齐;将来若给命令输出加复制/导出入口,也在那个出口剥。当前没有「复制走一屏转义序列」的路径:bash 输出不在复制范围里。 - 后台任务的日志文件保留原文(终端
tail会正常着色);后台任务面板(消息侧/tasks对话框、CLI 面板)按同一条渲染通道把转义序列渲染成颜色,不得把ESC[31m当可见字符显示在屏幕上;Process error: …这类工具自身的错误文案不是命令输出,仍在生成处剥离。 - 生效范围:四个 GUI 宿主(桌面端 / VS Code / JetBrains / 远控 Web 页)都生效;CLI 终端由 Ink 直接透传 ANSI(已实测:Ink 8 的
Text原样保留转义序列),本规格不定义 CLI 视觉。解析是纯字符串处理,不得改变既有的输出截断上限、max-height滚动与虚拟窗口几何。
用户故事:助手消息代码块的复制按钮(优先级:P1)
作为用户,我希望助手消息里每个围栏代码块右上角有一颗复制按钮,一键把这块代码的原文复制走(不带围栏标记、不带语言标记),以便把示例代码或命令直接贴进编辑器、终端——而不是在代码里用鼠标划选,还常常多选或少选一行。
为什么是这个优先级:对话里的代码块经常是「一条回复里若干段代码、只要其中一段」,回答越长划选越难。claude.ai 桌面端对每个代码块都给了这颗入口(复制成功回勾、可访问名 Copy code / Copied、位置在代码块右上角与语言名同一行),我们缺的正是这一层(P1)。
独立测试:渲染带语言标记与不带语言标记的围栏代码块,读 DOM 核对每个代码块都有一颗复制按钮、落位在代码块容器右上角;点击后核对剪贴板字符串逐字节等于该块的代码原文(去掉围栏行、语言标记与末尾那一个换行,保留缩进 / 内部空行 / 行尾空格);核对点击后的对勾反馈与恢复;渲染空代码块,核对没有按钮;核对高亮、块内链接、横向滚动与键盘可达均未改变。
验收场景:
- 假设助手消息里有一个围栏代码块(带不带语言标记都一样),当渲染完成,则这个代码块的右上角出现一颗复制按钮(图标按钮,无文字标签),落位在代码块自己的容器内(相对定位容器 + 右上角绝对定位),不随
pre的横向滚动而移动,也不占代码块高度。 - 假设代码块首行很长、宽度超出容器(出现横向滚动),当渲染完成,则按钮不遮挡首行文字:首行为按钮让出内边距,滚动时按钮仍在右上角不消失。
- 假设宿主环境支持 hover(桌面端 / VS Code / JetBrains / 远控页的鼠标环境),当鼠标没有悬停在该代码块上,则按钮不可见;悬停该代码块时淡入。假设设备没有 hover(远控页的手机触控),则按钮常显——触控用户没有「悬停」这一步,否则永远拿不到入口。这与消息右上角那组动作按钮同源:动作按钮只在悬停时出现,不常驻。
- 假设点击复制,则写入剪贴板的是这个代码块的代码原文:去掉围栏行与语言标记、去掉末尾那一个换行符,其余逐字节一致——缩进、内部空行、行尾空格、Unicode 字符,以及块内被链接化的 URL / 文件路径都不改变;高亮只影响 DOM 结构,不得影响复制出来的字符。
- 假设复制成功,则被点的那颗按钮图标切换为对勾、可访问名与 tooltip 短暂变为「已复制」,约 1.5 秒后自动恢复为复制图标与「复制代码」;反馈只作用于被点击的那个代码块。
- 假设复制失败(
navigator.clipboard与旧式execCommand两条路都不可用),则不给出成功反馈。 - 假设代码块去掉围栏后没有任何可见字符(空块),则不渲染复制按钮。
- 假设是用户消息,则不涉及本场景(用户消息不走 Markdown 渲染);推理块与压缩摘要块同样由 Markdown 渲染器产出代码块,行为与助手正文一致,不另立规则。
- 假设内容注入前经 DOMPurify 净化,则按钮所需的标签与属性必须同时放行到白名单——否则按钮被静默剥掉,屏幕上没有入口而单测看不出来(同「助手消息代码块的语法高亮」场景 5 的白名单约束)。
- 假设键盘用户在代码块上操作,则按钮是原生
button,可 Tab 聚焦、回车 / 空格触发,有可访问名;焦点样式沿用本规格「键盘可达与无障碍」那一套,不新增第二套焦点样式。 - 假设长会话里代码块很多、虚拟窗口按像素预算渲染,当滚动,则按钮不改变代码块几何(不参与高度计算),虚拟窗口行高估算与滚动行为与上线前一致。
- 生效范围:四个 GUI 宿主(桌面端 / VS Code / JetBrains / 远控 Web 页)都生效;CLI 终端不在本规格范围。
用户故事:链接角色与下划线时机(优先级:P1)
作为桌面端用户,我希望正文里的描述性链接看起来就是正文、而直接展示的网址/邮箱/电话看起来是可精确辨认的地址,并且链接在鼠标悬停或键盘聚焦时给出下划线提示。
为什么是这个优先级:链接是对话流里唯一的交互文本载体,「是不是链接」「能不能点」必须一眼可辨;判据错了会把「联系我们」渲染成地址、或把地址渲染成正文(P1)。
独立测试:构造描述性链接(查看预览/参考文档/发送邮件/联系我们/拨打电话)、直显地址(http(s) URL、mailto:/tel: 串、直接显示的邮箱、直接显示的电话)、日期样文本、代码内路径链接,逐元素核对归属角色与下划线时机;用真实键盘 Tab 路径验证聚焦态命中;核对每个链接的 href 逐字节未变。
验收场景:
- 假设链接的可见文字是描述性短语(如「查看预览」「参考文档」「发送邮件」「联系我们」「拨打电话」),当渲染完成,则使用正文 UI 角色(与所在正文一致),不加地址类标记;点击后按该链接既有规则打开(本规格不改跳转语义)。
- 假设链接的可见文字本身就是地址——scheme 地址(
http(s)://///)、显式mailto:/tel:串、直接显示的邮箱、直接显示的电话号码,当渲染完成,则归入代码角色,并保留长地址换行能力(不撑破消息区域)。 - 假设要判断一条链接是不是「直显地址」,当实现判定,则判据是可见文字的含义而不是有没有
//;判定不依赖某一条消息的具体内容。 - 假设正文里出现日期样文本(如
2026-09-10),当判定是否电话,则不被误判为电话号码。 - 假设链接的可见文字与
href不同源(label 与 href 不一致),当渲染完成,则角色判定只看可见文字,href原样输出、跳转行为不变。 - 假设链接处于静态态,当查看其呈现,则不显示下划线(仅靠颜色区分);假设鼠标悬停或键盘聚焦,则显示下划线并强化颜色对比(深色主题下改为提亮以体现同一反馈)。
- 假设正文里由纯文本自动链接化的文件路径或代码内的链接,当查看其下划线时机,则与正文内联链接同一时机(静态无、hover / focus 有);是否下划线不改变其可点击性。
用户故事:键盘可达与无障碍(优先级:P1)
作为使用键盘或读屏的用户,我希望能聚焦到每一处可滚动区域并看到焦点位置、能知道复选框勾选状态、能通过键盘展开/收起折叠块——而不是「内容能看却摸不到」。
为什么是这个优先级:可滚动区域不可聚焦、复选框无可访问名、折叠标题不是按钮,都是 WCAG 层面的硬缺陷(分别对应 2.1.1、4.1.2),且已由 axe 报出 critical/serious(P1)。
独立测试:对渲染结果跑 axe(浅深两主题)核对 label 与 scrollable-region-focusable 归零;用真实键盘 Tab 逐个走到代码块、命令输出、写入预览、表格滚动区、折叠标题,验证可达与可见焦点;对折叠标题用 Enter / Space 验证展开态切换;读 DOM 验证复选框属性。
验收场景:
- 假设消息中存在可滚动的代码块、bash 命令输出、写入预览、表格滚动区,当键盘用户 Tab(桌面端),则该区域可聚焦且焦点位置可见;插件端不新增该类停靠点(不得出现「看不见焦点」的停靠点)。
- 假设用户在代码块或命令输出内聚焦后,当按下方向键,则可滚动查看完整内容(内容不被静默裁切)。
- 假设消息中包含 Markdown 复选框(任务列表),当读 DOM,则每个复选框都有可访问名(已完成 / 未完成),且保留其勾选状态载体、不把控件隐藏或从无障碍树中移除。
- 假设消息中存在可折叠块(推理过程、压缩工具行等),当查看元素,则折叠标题是按钮(含展开态属性),键盘可达,Enter / Space 均可切换展开态并同步更新展开态属性;旧宿主不因此退化为浏览器默认按钮。
- 假设新增任一元素属性依赖 HTML 净化白名单(可访问名属性、tab 停靠点属性、表格包裹层元素与类名),当实现,则必须同时放行到净化白名单——否则属性/包裹层会被静默剥掉,修复到不了 DOM 而单测与走查却看不出差异。
用户故事:桌面状态指示的语义色(优先级:P2)
作为桌面端用户,我希望「进行中 / 已完成 / 失败 / 待确认 / 空闲」这类状态指示在浅色与深色主题下各自都可辨,而不是深色下沿用浅色取值、糊成一片。
为什么是这个优先级:状态指示不是排版主线,但同一状态的取值若只在一个主题下定义、或缺一档,对应主题会静默回退到不可辨的取值(P2)。
独立测试:在浅色与深色主题下渲染带运行中/已完成/失败/空闲状态的任务列表状态点(含行图标)与时间线节点,核对两主题下各自可辨;并断言每一处引用的状态语义在浅色与深色下都有定义。
验收场景:
- 假设界面处于浅色或深色主题,当渲染任务列表状态点与行图标、工具状态文字/图标、时间线节点,则取值来自同一套状态语义(进行中 / 已完成 / 失败 / 待确认 / 空闲),不在这几处各写各的。
- 假设查看桌面状态语义层,当核对取值,则浅色与深色各自取值:深色下不沿用浅色取值,使每种状态在两主题下都可辨。
- 假设某处引用某个状态语义,当该语义只在单一主题下定义或缺失,则属于缺陷(应有守卫测试覆盖引用集合与定义集合的一致性),避免退化为「某一主题下静默取到不可辨的取值」。
- 假设插件端不定义桌面状态语义,当在 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 · 无障碍」的部分结论已落在这里。