Files
Jyotisha/TASK-chat-streaming-ux-20260901.md
T
Jesse_Chen fbb80fa376
Independent Staging Quality Gate / validate (push) Failing after 10m9s
Independent Staging Quality Gate / publish (push) Has been skipped
docs(chat): add streaming-ux and dual-surface unification brief
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-01 21:13:45 +00:00

22 KiB
Raw Blame History

任务书 · Agent 聊天流式体验与双会话面统一(2026-09-01)

基线:origin/staging @ 80e77361

对标物是 Claude.ai 的对话面:吐字匀速、思考块有开合过渡、步骤行结算后收成一行、回答结束不闪、滚动跟随不抢手、校正会话和普通会话看起来是同一个产品。本轮四条任务全部来自对着 origin/staging 代码逐行审计,每条附了实证位置。先读完「硬红线」再动手。


事故实证(为什么现在不流畅)

下面所有行号基于 80e77361按选择器 / 符号定位,不要信行号。

A. 每个流式事件都同步触发一次整条消息的全量重渲染

  • frontend/src/hooks/use-consultation-run.ts createNdjsonParser 回调(约 :770:830):answer.delta / thinking.delta / thinking.section / activity 每一条事件都各自 setStreamingReply(...)。服务端 stream-agent-response.ts outputText:340:362)是每个模型 text-delta 立即 send 一条 answer.delta,没有任何合并。reader.read() 每次 resolve 都是独立 microtaskReact 19 不会把它们合并成一帧。
  • 每次 setStreamingReply 都要跑 parseAgentReply(answer) + applyThinkingSectionProgress(...)(对全文),再渲染 StreamingMessageEntryChatMessageContentsplitSpokenAnswerAndTechniqueAudit(text)promoteDefinitionLists(text)react-markdown 对整段部分回答重新 parse。回答长到 3–4k 字时是 O(n²),肉眼可见的卡顿就是它。
  • frontend/src/components/rectification-agentic-chat.tsx send(: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 会卸载前者、挂载后者,ChatMessageRowuseEntryEffect 在新挂载的 <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.tsx GSAP duration: 0.18。同一元素两个 fade 叠加,且时长不一致(160 vs 180)。

C. 流式期间 timeline 是受控 open={true}

每来一个 delta 重渲染一次,用户点 summary 折叠后下一个 token 又被撑开。流式期间用户无法折叠思考块。

D. 滚动跟随写了两份,行为不同

  • 普通会话:page.tsx 约 :1229:1236 一个 effect,依赖 activeStreamingText每个 token 触发一次 scrollToloading 时 auto),与 A 的重渲染同帧叠加。结算瞬间 isLoading 翻 false,同一 effect 再跑一次 smooth 滚动——用户看到"先跳到底、再平滑滚一下"。
  • 校正会话:rectification-agentic-chat.tsx followLatestContent:381:392)用 rAF 合并后直接赋 scrollTop,另有 rectification-sticky-scroll.ts 一套阈值。
  • 「跳到最新」按钮也是两份:page.tsx :2375 用 Tailwind 内联类(shadow-mdbg-canvas,绕开 §7 只允许 --shadow-elevated 的规则),文案「跳到最新」带图标;校正面 .rectification-jump-latest--shadow-soft,文案「回到最新」无图标。

E. 校正会话和普通会话是两套 UI

维度 普通会话 校正会话
消息模型 ChatMessagesettledChatMessageViews 永远给 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 又把格子压到 14pxcanvas 溢出裁切
活动文案 无 shimmer agent-activity-status__text shimmer
输入框 ChatComposermaxLength 500 + 接近上限计数,BUG-447 <Textarea>没有 maxLength、没有计数
阅读宽度 .message-list 900px 720px
失败态 composer notice 消息上方红色 rectification-activity-failure 一行
重新生成 走真实管线 立刻把消息改成假的「正在组织回答…」answer-composition

chat-message-row.tsx 里为此并存三条思考渲染路径:ConsultationRunTimelinetimeline 有值)、ConsultationThinkingReport+ThinkingStepTreesections 有值且 timeline 无值)、AgentActivityStatus(其余)。普通会话 timeline 永远有值,所以后两条对普通会话是死路径,只有校正在用第三条;第二条目前没有任何调用方能走到grep ConsultationThinkingReport 只有 chat-message-row.tsx 一处引用,且被 consultTimeline === undefined && thinkingSections.length > 0 门住,校正的 RenderMessage 不带 sections)。

一条不是缺陷、本轮不许改的差异:校正的 thinking.deltarectification-agentic/v9/stream-mapping.ts 公开边界(if (event.type === "thinking.delta") return null;)被有意丢弃,校正会话本来就不展示思考正文。统一的是壳,不是内容。

F. DESIGN.md 与实现不一致(见任务 4)


硬红线

  1. 不得改服务端事件语义。 consultation-run-timeline.ts reducer 有 tests/consultation-run-timeline.test.ts 锁着;校正 thinking.delta 的丢弃是隐私边界,不许打开。本轮只动客户端渲染、状态合并与样式。
  2. 不得手写 useCallback / useMemoReact Compiler)。rectification-agentic-chat.tsx 里既有的 useCallback 不要顺手删,也不要新增。
  3. 不得修改既有测试断言,除非该断言锁住的正是本轮要修的缺陷;那种情况在断言上方注释「原值是什么、为什么它是错的」,PROGRESS 单列。已知会碰到的:tests/chat-stream-layout.test.ts 的源码正则锁(LoaderCircleshowActivity = message.state !== "settled"stackedThinkingAndAnswer 等)和 tests/home-streaming-render-split.test.tsstreamingRowRenders === tokens.length
  4. 推 staging 前 ./node_modules/.bin/tsc --noEmit 通过。不要用 npx tsc
  5. 测试数不得低于基线,fail=0skipped=0(有 Docker 的环境);本机无 Docker 的既有缺口逐条比对,不得新增。
  6. 浅色、深色两套都验;prefers-reduced-motion: reduce 下所有新增过渡必须收成一帧。
  7. 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,先交 02。
  8. 不得改 .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),两个会话面共用:

  1. 帧合并:所有流式事件先落到一个 mutable 累加器(answer 文本、thinking 文本、timeline state、activity),用 requestAnimationFrame 合并成每帧最多一次 setState。页面不可见(document.hidden)时退化为 250ms setTimeoutrun.completed / run.failed / abort 时同步冲干净,不能让最后几个字延迟到下一帧之后才出现。
  2. 匀速释放:answer 文本不直接把网络到达的整块吐出,而是按帧释放:每帧释放的字符数 = max(2, ceil(pendingChars / 12))(约 200ms 内追平积压,网络快时自动加速,慢时不会"一顿一顿")。thinking 文本同样处理。结算时把剩余全部冲出。这是 Claude.ai 那种"字是流出来的"的手感来源,不是 CSS 能做出来的。
  3. 重活只做一次parseAgentReply / applyThinkingSectionProgress 只在帧 flush 时对当前释放到的文本跑一次,不再每事件一次。
  4. 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.tsxsend 循环。校正面 attempt.reset 时要把缓冲器清零。

验收

  • tests/home-streaming-render-split.test.ts:把「streamingRowRenders === tokens.length」改成「≤ ceil(tokens.length / 每帧合并数)≥ 1」,注释写明原断言是现状快照。用假 rAF(node:test mock 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)· 结算不闪、思考块有开合、流式期间可折叠

做法

  1. 同一条 assistant 行只有一个组件身份。 chat-transcript.tsx:最后一条 assistant 视图(不论 streaming 还是刚 settled)都由同一个 LatestAssistantEntry 渲染,ChatTranscript 用「messages 最后一条是否 assistant」决定它拿 settled 数据还是 streaming 数据;SettledMessageList 只渲染倒数第二条及之前。这样 streaming→settled 不卸载、不重放入场。渲染次数探针(home-streaming-render-probe.ts)加一个 latestEntryMounts 计数,契约断言一次会话内只 mount 一次。
  2. 入场动画二选一。 保留 GSAP(它已经做了 reduced-motion 分支),删掉 .message 上的 CSS message-enterGSAP 时长改回 0.16s 与 DESIGN.md §6 一致。useEntryEffect 依赖数组保持 [message.role] 不变(它就是只在 mount 跑)。
  3. 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-sizeSafari 未落地)。summary 文案「正在分析」→「已完成 N 步」在同一元素上切换,加 120ms 淡入即可。prefers-reduced-motion 下过渡为 0。
  4. live 行文案 shimmer 统一。 agent-activity-status__text 的 shimmer 提到 consultation-run-timeline__label 的 live 行也用(同一份 keyframe,不要复制第二份)。

验收

  • 契约:chat-transcript.tsx 源码不再同时存在 StreamingMessageEntrySettledMessageEntry 两个渲染最后一条的分支;consultation-run-timeline.tsxkey={live
  • globals.cssmessage-enter keyframe 不再被 .message 引用(tests/chat-stream-layout.test.ts 若锁了它,按红线 3 处理)。
  • 手工:录屏,回答结束瞬间消息不闪、timeline 折叠有过渡、下方回答不跳;流式期间点 summary 能折叠且不被下一个 token 撑开。

建档

BUG-471(结算重挂重放入场动画)、BUG-472(流式期间 timeline 不可折叠)。


任务 2(P1)· 校正会话接入同一套活动 UI,收敛三条思考渲染路径

做法

  1. 校正 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 行的 sources chipsfailedToollabel 带「未完成」后缀。RenderMessage 多一个 timeline 字段由适配器派生;ChatMessageRow 对校正消息也就自然走 ConsultationRunTimeline
  2. 结算后校正也收成一行CompletedActivityReceipt 第二个 <details> 删除,内容并入上面的 sources;rectification-activity-receipt 相关 CSS 删除。tests/rectification-activity-receipt.test.ts 锁的是 reducerrectification-activity-receipt.ts),不动 reducer 就不会红;若它锁了组件源码,按红线 3。
  3. 删死路径。 确认无调用方后删除 consultation-thinking-report.tsxthinking-step-tree.tsx,以及 chat-message-row.tsx 里的 showReport 分支。AgentActivityStatus 只保留「无 trace、无 timeline 的兜底」(onboarding 等地方若在用要先 grep),其 trace 分支删除。
  4. live 标记统一为 InlineSpinner,移除 thinking-orbs 决策见文末。agent-activity-status.tsxThinkingOrbInlineSpinner size={12}package.json 去依赖;.conversation.is-rectification .agent-thinking-step / .agent-thinking-marker 两条 14px 覆写删除。
  5. 失败态与重新生成对齐。 校正的「回答未完成」改为和普通会话一样走 composer noticerectification-agentic-chat.tsx 已有 error state,直接复用,删除 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.tsstarted/completed/failed 三种 trace 映射;方法 chips 去重且 ≤8;attempt.reset 后为空。
  • 手工(无登录态则用 docs/testing/staging-manual-walkthrough-20260901.md 的方式留给用户):一次校正对话,流式期间与结算后截图,和普通会话并排——标记、行高、折叠行为一致。

建档

BUG-473(校正会话活动 UI 与普通会话不一致,含 14px 裁切)。


任务 3(P1,依赖第三批拆页合入)· 校正面复用 ChatComposer 与统一滚动跟随

做法

  1. rectification-agentic-chat.tsx 的裸 <Textarea> + 两个按钮换成 ChatComposermaxLength=500,接近上限计数随之而来;inputLabel / placeholder 保持现有三段文案)。ChatComposer 目前从 composer-draft 外部 store 读草稿,校正面有自己的 draft state——给 ChatComposer 加一个可选 value prop(有值则受控,无值则读 store),不要让校正面写主会话的草稿 store。tests/composer-isolation-contract.test.tscomposer-ime-contract 会锁到,按红线 3。
  2. 滚动跟随只留 useConversationScrollAnchor 一份:校正面接入它(active = 面板打开,resetKey = caseId),删除 rectification-sticky-scroll.tsfollowTailRef / scrollFrameRef / updateFollowState / followLatestContent / showJumpToLatest。跟随本身改成 hook 内部的 rAF 合并(把 page.tsx 那个按 activeStreamingText 触发的 effect 搬进 hook,改为「anchored 且内容高度变化时,下一帧 scrollTop = scrollHeight」,用 ResizeObserver 监听 .message-list 而不是依赖 token 文本)。结算时不再补一次 smooth 滚动。
  3. 「跳到最新」抽成 frontend/src/components/jump-to-latest-button.tsx,样式走 globals.css token--shadow-elevated--color-canvas),删除 page.tsx 内联 Tailwind 版本和 .rectification-jump-latest。文案统一「跳到最新」带 ArrowDown
  4. 校正 .message-list 720px 覆写删除,与普通会话同 900px;候选卡片等自身 max-width 不受影响。

验收

  • tests/chat-notice-and-scroll-contract.test.tstests/birth-time-mobile-scroll-contract.test.ts 保持绿或按红线 3 处理。
  • 契约:rectification-agentic-chat.tsx 引用 ChatComposeruseConversationScrollAnchor;仓库无 rectification-sticky-scrollpage.tsxshadow-md
  • 手工:两个会话面各做一次「流式中向上滚→停止跟随→出现按钮→点击回到底并恢复跟随」。

建档

BUG-474(校正输入框无字数上限)、BUG-475(滚动跟随双实现)。


任务 4P2)· DESIGN.md 对齐

与代码同 PR 改,不单独提。逐条:

  1. §5 Message 新增小节「Streaming states」:queued → loading-method → calculating → thinking → composing → settled → failed 七态;每态可见什么(timeline 行、live 标记、shimmer 文案、回答正文);态间过渡各用哪一档动效;failed 用 composer notice 而不是消息内红字。写明校正会话共用同一套壳,仅不展示思考正文(服务端边界)。
  2. §5 Message → Typography「assistant body 16px」与 .message-markdown { font-size: 17px } 不符,二选一改齐(建议文档改 17,代码已上线很久)。
  3. §5 Message → Motion「new messages enter with a short opacity/translate transition only」改为写明只有 GSAP 一份、160ms,且 streaming→settled 不重放。
  4. §6 表新增三行:Thinking collapse 180msTimeline label swap 120msText reveal — per-frame release, not a duration
  5. §9 等待表:「流式生成中」一行删掉 agent-activity-shimmer 之外的描述;ThinkingOrb 若按决策移除,就不用写;若保留,必须作为第四类进表并写清与 InlineSpinner 的分工。
  6. §4「Chat reading width」:写明两个会话面统一 900px(任务 3 完成后)或写明 720 的理由。
  7. §5 Input and composer:加一句「The rectification surface renders the same ChatComposer; there is no second composer」。
  8. **§5 新增「Jump to latest」**组件条目:结构、出现条件(离底 >96px 且非用户主动滚动到底)、样式 token、44px 目标。
  9. §7:写明 --shadow-soft 的用途(现在只有 --shadow-elevated 入文档,但 CSS 有两个 token 且都在用)。
  10. §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 编号、未做与原因。