Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
22 KiB
任务书 · Agent 聊天流式体验与双会话面统一(2026-09-01)
基线:origin/staging @ 80e77361。
对标物是 Claude.ai 的对话面:吐字匀速、思考块有开合过渡、步骤行结算后收成一行、回答结束不闪、滚动跟随不抢手、校正会话和普通会话看起来是同一个产品。本轮四条任务全部来自对着 origin/staging 代码逐行审计,每条附了实证位置。先读完「硬红线」再动手。
事故实证(为什么现在不流畅)
下面所有行号基于 80e77361,按选择器 / 符号定位,不要信行号。
A. 每个流式事件都同步触发一次整条消息的全量重渲染
frontend/src/hooks/use-consultation-run.tscreateNdjsonParser回调(约 :770–:830):answer.delta/thinking.delta/thinking.section/activity每一条事件都各自setStreamingReply(...)。服务端stream-agent-response.tsoutputText(:340–:362)是每个模型 text-delta 立即 send 一条answer.delta,没有任何合并。reader.read()每次 resolve 都是独立 microtask,React 19 不会把它们合并成一帧。- 每次
setStreamingReply都要跑parseAgentReply(answer)+applyThinkingSectionProgress(...)(对全文),再渲染StreamingMessageEntry→ChatMessageContent→splitSpokenAnswerAndTechniqueAudit(text)→promoteDefinitionLists(text)→react-markdown对整段部分回答重新 parse。回答长到 3–4k 字时是 O(n²),肉眼可见的卡顿就是它。 frontend/src/components/rectification-agentic-chat.tsxsend(:559–:700)同样:每个事件一次setMessages(current => current.map(...)),整份消息数组重建。tests/home-streaming-render-split.test.ts锁的是"settled 列表只渲染一次",没有锁"每 token 一次 streaming 行渲染"是不是合理(它断言streamingRowRenders === tokens.length,这条是现状的写照,不是目标)。
B. 回答结束那一刻会闪一下
chat-transcript.tsx:流式中最后一条走StreamingMessageEntry,结算后走SettledMessageEntry。两者是不同组件、不同树位置,renderKey相同也没用——React 会卸载前者、挂载后者,ChatMessageRow的useEntryEffect在新挂载的<article>上重放 GSAP 入场动画(autoAlpha 0→1、y 12→0),用户正在读的回答整体闪一次。tests/chat-stream-layout.test.ts第一条"keeps the assistant render identity stable"想锁的正是这件事,但只锁了renderKey字符串,没锁组件身份。consultation-run-timeline.tsx:<details key={live ? "live" : "settled"} open={live ? true : undefined}>,结算时整个 timeline 重挂,从展开直接跳到折叠,summary 文案从「正在分析」瞬间变「已完成 N 步」,下方回答向上跳一段,没有任何过渡。- 入场动画写了两份:
globals.css.message { animation: message-enter 160ms }和chat-message-row.tsxGSAPduration: 0.18。同一元素两个 fade 叠加,且时长不一致(160 vs 180)。
C. 流式期间 timeline 是受控 open={true}
每来一个 delta 重渲染一次,用户点 summary 折叠后下一个 token 又被撑开。流式期间用户无法折叠思考块。
D. 滚动跟随写了两份,行为不同
- 普通会话:
page.tsx约 :1229–:1236 一个 effect,依赖activeStreamingText,每个 token 触发一次scrollTo(loading 时auto),与 A 的重渲染同帧叠加。结算瞬间isLoading翻 false,同一 effect 再跑一次smooth滚动——用户看到"先跳到底、再平滑滚一下"。 - 校正会话:
rectification-agentic-chat.tsxfollowLatestContent(:381–:392)用 rAF 合并后直接赋scrollTop,另有rectification-sticky-scroll.ts一套阈值。 - 「跳到最新」按钮也是两份:
page.tsx:2375 用 Tailwind 内联类(shadow-md、bg-canvas,绕开 §7 只允许--shadow-elevated的规则),文案「跳到最新」带图标;校正面.rectification-jump-latest用--shadow-soft,文案「回到最新」无图标。
E. 校正会话和普通会话是两套 UI
| 维度 | 普通会话 | 校正会话 |
|---|---|---|
| 消息模型 | ChatMessage → settledChatMessageViews 永远给 assistant 挂 timeline |
自有 RenderMessage + activityTrace,无 timeline |
| 活动面板 | ConsultationRunTimeline(<details>,结算收成「已完成 N 步」一行) |
AgentActivityStatus trace 路径(<ol> 永远全展开,结算后不折叠)+ 第二个 <details> rectification-activity-receipt |
| live 标记 | InlineSpinner 12px |
ThinkingOrb canvas 20px,但 .conversation.is-rectification .agent-thinking-marker 又把格子压到 14px,canvas 溢出裁切 |
| 活动文案 | 无 shimmer | agent-activity-status__text shimmer |
| 输入框 | ChatComposer(maxLength 500 + 接近上限计数,BUG-447) |
裸 <Textarea>,没有 maxLength、没有计数 |
| 阅读宽度 | .message-list 900px |
720px |
| 失败态 | composer notice | 消息上方红色 rectification-activity-failure 一行 |
| 重新生成 | 走真实管线 | 立刻把消息改成假的「正在组织回答…」answer-composition 态 |
chat-message-row.tsx 里为此并存三条思考渲染路径:ConsultationRunTimeline(timeline 有值)、ConsultationThinkingReport+ThinkingStepTree(sections 有值且 timeline 无值)、AgentActivityStatus(其余)。普通会话 timeline 永远有值,所以后两条对普通会话是死路径,只有校正在用第三条;第二条目前没有任何调用方能走到(grep ConsultationThinkingReport 只有 chat-message-row.tsx 一处引用,且被 consultTimeline === undefined && thinkingSections.length > 0 门住,校正的 RenderMessage 不带 sections)。
一条不是缺陷、本轮不许改的差异:校正的 thinking.delta 在 rectification-agentic/v9/stream-mapping.ts 公开边界(if (event.type === "thinking.delta") return null;)被有意丢弃,校正会话本来就不展示思考正文。统一的是壳,不是内容。
F. DESIGN.md 与实现不一致(见任务 4)
硬红线
- 不得改服务端事件语义。
consultation-run-timeline.tsreducer 有tests/consultation-run-timeline.test.ts锁着;校正thinking.delta的丢弃是隐私边界,不许打开。本轮只动客户端渲染、状态合并与样式。 - 不得手写
useCallback/useMemo(React Compiler)。rectification-agentic-chat.tsx里既有的useCallback不要顺手删,也不要新增。 - 不得修改既有测试断言,除非该断言锁住的正是本轮要修的缺陷;那种情况在断言上方注释「原值是什么、为什么它是错的」,PROGRESS 单列。已知会碰到的:
tests/chat-stream-layout.test.ts的源码正则锁(LoaderCircle、showActivity = message.state !== "settled"、stackedThinkingAndAnswer等)和tests/home-streaming-render-split.test.ts的streamingRowRenders === tokens.length。 - 推 staging 前
./node_modules/.bin/tsc --noEmit通过。不要用npx tsc。 - 测试数不得低于基线,
fail=0、skipped=0(有 Docker 的环境);本机无 Docker 的既有缺口逐条比对,不得新增。 - 浅色、深色两套都验;
prefers-reduced-motion: reduce下所有新增过渡必须收成一帧。 page.tsx冲突:TASK-home-split-batch3-20260901.md正在拆page.tsx。本轮任务 0、1、2 不碰page.tsx;任务 3 要动page.tsx两处(滚动 effect、跳到最新按钮),必须等第三批合入后再做。开工前git log origin/staging -- frontend/src/app/page.tsx确认,有未验收改动就把任务 3 登记BLOCKED.md,先交 0–2。- 不得改
.gitea/workflows/**。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
让步顺序:功能与测试不回归 > 可验证的修复 > 视觉一致 > 代码整洁。
开工前置
git fetch origin --prune
git worktree add -b codex/streaming-ux-20260901 \
../.worktrees/streaming-ux-20260901 origin/staging
读 frontend/AGENTS.md(Next 16.3 与训练数据不同,先看 node_modules/next/dist/docs/),pre_work_error_ledger.md,跑 scripts/pre_work_check.py。改前在 docs/BUG_HISTORY.md 检索同类。BUG 编号先看远端最大号(当前是 469),本轮从 470 起。
任务 0(P0)· 流式渲染按帧合并 + 匀速吐字
做法
新建 frontend/src/lib/stream-frame-buffer.ts(纯函数 + 一个薄 hook),两个会话面共用:
- 帧合并:所有流式事件先落到一个 mutable 累加器(answer 文本、thinking 文本、timeline state、activity),用
requestAnimationFrame合并成每帧最多一次setState。页面不可见(document.hidden)时退化为 250mssetTimeout。run.completed/run.failed/ abort 时同步冲干净,不能让最后几个字延迟到下一帧之后才出现。 - 匀速释放:answer 文本不直接把网络到达的整块吐出,而是按帧释放:每帧释放的字符数 =
max(2, ceil(pendingChars / 12))(约 200ms 内追平积压,网络快时自动加速,慢时不会"一顿一顿")。thinking 文本同样处理。结算时把剩余全部冲出。这是 Claude.ai 那种"字是流出来的"的手感来源,不是 CSS 能做出来的。 - 重活只做一次:
parseAgentReply/applyThinkingSectionProgress只在帧 flush 时对当前释放到的文本跑一次,不再每事件一次。 - Markdown 前缀记忆:
chat-message-content.tsx把文本按最后一个\n\n切成「已稳定前缀」和「尾块」:前缀交给一个用前缀内容做 key 的 memo 子组件(前缀不变就不重新 parse),只有尾块每帧重 parse。切分对象是splitSpokenAnswerAndTechniqueAudit(text).spoken,不是原始text——技法审计表(AUDIT_HEADER起)必须留在审计折叠里,不能被当成前缀 parse 进正文;parseAgentReply处理的<!--AYANAM_*标记在服务端createVisibleTextTransformer已剥离,客户端不需要再防。
两个会话面都接入:use-consultation-run.ts 的 NDJSON 回调和 rectification-agentic-chat.tsx 的 send 循环。校正面 attempt.reset 时要把缓冲器清零。
验收
tests/home-streaming-render-split.test.ts:把「streamingRowRenders === tokens.length」改成「≤ ceil(tokens.length / 每帧合并数)且≥ 1」,注释写明原断言是现状快照。用假 rAF(node:testmock timers)喂 200 个 token,断言 streaming 行渲染次数 ≤ 20。- 新增
tests/stream-frame-buffer.test.ts:匀速释放的字符数公式、结算冲干净、document.hidden退化、reset 清零。 - 新增契约:
chat-message-content.tsx存在前缀/尾块切分,且前缀不变时renderChatMarkdown调用次数不增(用注入的 renderer 计数)。 - 手工:Chrome Performance 录一次 3k 字回答,流式期间无 >50ms 长任务;录屏对比改前改后(浅色即可)。
建档
BUG-470(流式重渲染 O(n²) 导致卡顿)。防复发:任何流式状态必须经 stream-frame-buffer,不得在事件回调里直接 setState。
任务 1(P0)· 结算不闪、思考块有开合、流式期间可折叠
做法
- 同一条 assistant 行只有一个组件身份。
chat-transcript.tsx:最后一条 assistant 视图(不论 streaming 还是刚 settled)都由同一个LatestAssistantEntry渲染,ChatTranscript用「messages最后一条是否 assistant」决定它拿 settled 数据还是 streaming 数据;SettledMessageList只渲染倒数第二条及之前。这样 streaming→settled 不卸载、不重放入场。渲染次数探针(home-streaming-render-probe.ts)加一个latestEntryMounts计数,契约断言一次会话内只 mount 一次。 - 入场动画二选一。 保留 GSAP(它已经做了 reduced-motion 分支),删掉
.message上的 CSSmessage-enter;GSAP 时长改回 0.16s 与 DESIGN.md §6 一致。useEntryEffect依赖数组保持[message.role]不变(它就是只在 mount 跑)。 - timeline 结算过渡。
consultation-run-timeline.tsx去掉key={live ? "live" : "settled"}。live 期间<details>非受控:defaultOpen(首次挂载 open),用户折叠后不再被撑开。结算时若用户没手动动过,则程序折叠——折叠用 180ms 高度过渡:.consultation-run-timeline__list包一层display: grid; grid-template-rows: 1fr → 0fr; transition: grid-template-rows 180ms var(--ease-out),内层min-height: 0; overflow: hidden(不要用interpolate-size,Safari 未落地)。summary 文案「正在分析」→「已完成 N 步」在同一元素上切换,加 120ms 淡入即可。prefers-reduced-motion下过渡为 0。 - live 行文案 shimmer 统一。
agent-activity-status__text的 shimmer 提到consultation-run-timeline__label的 live 行也用(同一份 keyframe,不要复制第二份)。
验收
- 契约:
chat-transcript.tsx源码不再同时存在StreamingMessageEntry与SettledMessageEntry两个渲染最后一条的分支;consultation-run-timeline.tsx无key={live。 globals.css中message-enterkeyframe 不再被.message引用(tests/chat-stream-layout.test.ts若锁了它,按红线 3 处理)。- 手工:录屏,回答结束瞬间消息不闪、timeline 折叠有过渡、下方回答不跳;流式期间点 summary 能折叠且不被下一个 token 撑开。
建档
BUG-471(结算重挂重放入场动画)、BUG-472(流式期间 timeline 不可折叠)。
任务 2(P1)· 校正会话接入同一套活动 UI,收敛三条思考渲染路径
做法
- 校正 trace → timeline 行。 新建
frontend/src/lib/rectification-timeline-adapter.ts:把AgentActivityTraceItem[]+CompletedActivityReceiptView映射成ConsultationTimelineRow[]——activity项 →kind: "calculate"(label 沿用RECTIFICATION_TOOL_PROGRESS_LABELS/_DONE_LABELS),回执里的方法(PUBLIC_RECTIFICATION_METHOD_LABELS)作为该 calculate 行的sourceschips,failedTool用label带「未完成」后缀。RenderMessage多一个timeline字段由适配器派生;ChatMessageRow对校正消息也就自然走ConsultationRunTimeline。 - 结算后校正也收成一行。
CompletedActivityReceipt第二个<details>删除,内容并入上面的 sources;rectification-activity-receipt相关 CSS 删除。tests/rectification-activity-receipt.test.ts锁的是 reducer(rectification-activity-receipt.ts),不动 reducer 就不会红;若它锁了组件源码,按红线 3。 - 删死路径。 确认无调用方后删除
consultation-thinking-report.tsx、thinking-step-tree.tsx,以及chat-message-row.tsx里的showReport分支。AgentActivityStatus只保留「无 trace、无 timeline 的兜底」(onboarding 等地方若在用要先 grep),其 trace 分支删除。 - live 标记统一为
InlineSpinner,移除thinking-orbs。 决策见文末。agent-activity-status.tsx的ThinkingOrb改InlineSpinner size={12};package.json去依赖;.conversation.is-rectification .agent-thinking-step / .agent-thinking-marker两条 14px 覆写删除。 - 失败态与重新生成对齐。 校正的「回答未完成」改为和普通会话一样走 composer notice(
rectification-agentic-chat.tsx已有errorstate,直接复用,删除rectification-activity-failure那行)。重新生成时不再伪造answer-composition活动,改为state: "thinking"+ 空 timeline,由真实事件填充。
验收
tests/chat-bundle-splitting-contract.test.ts之类若锁了thinking-orbs动态导入,按红线 3。grep -rn thinking-orbs frontend/src frontend/package.json为 0。- 新增
tests/rectification-timeline-adapter.test.ts:started/completed/failed 三种 trace 映射;方法 chips 去重且 ≤8;attempt.reset后为空。 - 手工(无登录态则用
docs/testing/staging-manual-walkthrough-20260901.md的方式留给用户):一次校正对话,流式期间与结算后截图,和普通会话并排——标记、行高、折叠行为一致。
建档
BUG-473(校正会话活动 UI 与普通会话不一致,含 14px 裁切)。
任务 3(P1,依赖第三批拆页合入)· 校正面复用 ChatComposer 与统一滚动跟随
做法
rectification-agentic-chat.tsx的裸<Textarea>+ 两个按钮换成ChatComposer(maxLength=500,接近上限计数随之而来;inputLabel/placeholder保持现有三段文案)。ChatComposer目前从composer-draft外部 store 读草稿,校正面有自己的draftstate——给ChatComposer加一个可选valueprop(有值则受控,无值则读 store),不要让校正面写主会话的草稿 store。tests/composer-isolation-contract.test.ts、composer-ime-contract会锁到,按红线 3。- 滚动跟随只留
useConversationScrollAnchor一份:校正面接入它(active= 面板打开,resetKey= caseId),删除rectification-sticky-scroll.ts、followTailRef/scrollFrameRef/updateFollowState/followLatestContent/showJumpToLatest。跟随本身改成 hook 内部的 rAF 合并(把page.tsx那个按activeStreamingText触发的 effect 搬进 hook,改为「anchored 且内容高度变化时,下一帧scrollTop = scrollHeight」,用ResizeObserver监听.message-list而不是依赖 token 文本)。结算时不再补一次smooth滚动。 - 「跳到最新」抽成
frontend/src/components/jump-to-latest-button.tsx,样式走globals.csstoken(--shadow-elevated、--color-canvas),删除page.tsx内联 Tailwind 版本和.rectification-jump-latest。文案统一「跳到最新」带ArrowDown。 - 校正
.message-list720px 覆写删除,与普通会话同 900px;候选卡片等自身max-width不受影响。
验收
tests/chat-notice-and-scroll-contract.test.ts、tests/birth-time-mobile-scroll-contract.test.ts保持绿或按红线 3 处理。- 契约:
rectification-agentic-chat.tsx引用ChatComposer与useConversationScrollAnchor;仓库无rectification-sticky-scroll;page.tsx无shadow-md。 - 手工:两个会话面各做一次「流式中向上滚→停止跟随→出现按钮→点击回到底并恢复跟随」。
建档
BUG-474(校正输入框无字数上限)、BUG-475(滚动跟随双实现)。
任务 4(P2)· DESIGN.md 对齐
与代码同 PR 改,不单独提。逐条:
- §5 Message 新增小节「Streaming states」:
queued → loading-method → calculating → thinking → composing → settled → failed七态;每态可见什么(timeline 行、live 标记、shimmer 文案、回答正文);态间过渡各用哪一档动效;failed用 composer notice 而不是消息内红字。写明校正会话共用同一套壳,仅不展示思考正文(服务端边界)。 - §5 Message → Typography「assistant body 16px」与
.message-markdown { font-size: 17px }不符,二选一改齐(建议文档改 17,代码已上线很久)。 - §5 Message → Motion「new messages enter with a short opacity/translate transition only」改为写明只有 GSAP 一份、160ms,且 streaming→settled 不重放。
- §6 表新增三行:
Thinking collapse 180ms、Timeline label swap 120ms、Text reveal — per-frame release, not a duration。 - §9 等待表:「流式生成中」一行删掉
agent-activity-shimmer之外的描述;ThinkingOrb若按决策移除,就不用写;若保留,必须作为第四类进表并写清与InlineSpinner的分工。 - §4「Chat reading width」:写明两个会话面统一 900px(任务 3 完成后)或写明 720 的理由。
- §5 Input and composer:加一句「The rectification surface renders the same
ChatComposer; there is no second composer」。 - **§5 新增「Jump to latest」**组件条目:结构、出现条件(离底 >96px 且非用户主动滚动到底)、样式 token、44px 目标。
- §7:写明
--shadow-soft的用途(现在只有--shadow-elevated入文档,但 CSS 有两个 token 且都在用)。 - §3:思考正文长段落用 13px 违反「body copy never below 14px」,把 timeline 内
thinkingText改 14px 或在文档写明例外。
决策记录
移除 thinking-orbs,全部 live 标记用 InlineSpinner + shimmer 文案。 理由:它只在校正面和一条死路径里用;每个 live 行一个 canvas + MutationObserver 主题探测 + 动态导入 20px 占位;DESIGN.md §9 从未把它列入等待词汇;并且它与 14px 容器的裁切就是本轮要修的缺陷之一。Claude.ai 的思考态也只是 shimmer 文字加一个小 spinner。若产品方想保留 orb,则改成反向决策:全部 live 标记用 orb,InlineSpinner 从 timeline 里退出——只能二选一,不许并存。执行前如无异议按移除做。
执行顺序
任务 0 → 任务 1 → 任务 2 可连续做,都不碰 page.tsx。任务 3 等 TASK-home-split-batch3 合入。任务 4 随各任务同 PR 更新对应小节,最后一条 PR 补齐剩余。
PROGRESS 要求
PROGRESS-chat-streaming-ux-20260901.md:每任务列改动文件、被触碰的测试断言(原值/新值/理由)、渲染次数探针前后数字、Performance 长任务前后数字、BUG 编号、未做与原因。