Files
Jyotisha/TASK-chat-streaming-ux-20260901.md
T
Jesse_ChenandClaude Fable 5.1 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

200 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务书 · 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(...)`(对全文),再渲染 `StreamingMessageEntry``ChatMessageContent``splitSpokenAnswerAndTechniqueAudit(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 会卸载前者、挂载后者,`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.tsx` GSAP `duration: 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.tsx` `followLatestContent`: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)
---
## 硬红线
1. **不得改服务端事件语义。** `consultation-run-timeline.ts` reducer 有 `tests/consultation-run-timeline.test.ts` 锁着;校正 `thinking.delta` 的丢弃是隐私边界,不许打开。本轮只动客户端渲染、状态合并与样式。
2. **不得手写 `useCallback` / `useMemo`**React Compiler)。`rectification-agentic-chat.tsx` 里既有的 `useCallback` 不要顺手删,也不要新增。
3. **不得修改既有测试断言**,除非该断言锁住的正是本轮要修的缺陷;那种情况在断言上方注释「原值是什么、为什么它是错的」,PROGRESS 单列。已知会碰到的:`tests/chat-stream-layout.test.ts` 的源码正则锁(`LoaderCircle``showActivity = message.state !== "settled"``stackedThinkingAndAnswer` 等)和 `tests/home-streaming-render-split.test.ts``streamingRowRenders === tokens.length`
4. 推 staging 前 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`**。
5. 测试数不得低于基线,`fail=0``skipped=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。
让步顺序:功能与测试不回归 > 可验证的修复 > 视觉一致 > 代码整洁。
## 开工前置
```bash
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 `setTimeout``run.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.tsx``send` 循环。校正面 `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-enter`GSAP 时长改回 **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-size`Safari 未落地)。summary 文案「正在分析」→「已完成 N 步」在同一元素上切换,加 120ms 淡入即可。`prefers-reduced-motion` 下过渡为 0。
4. **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-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` chips`failedTool``label` 带「未完成」后缀。`RenderMessage` 多一个 `timeline` 字段由适配器派生;`ChatMessageRow` 对校正消息也就自然走 `ConsultationRunTimeline`
2. **结算后校正也收成一行**`CompletedActivityReceipt` 第二个 `<details>` 删除,内容并入上面的 sources;`rectification-activity-receipt` 相关 CSS 删除。`tests/rectification-activity-receipt.test.ts` 锁的是 reducer`rectification-activity-receipt.ts`),不动 reducer 就不会红;若它锁了组件源码,按红线 3。
3. **删死路径。** 确认无调用方后删除 `consultation-thinking-report.tsx``thinking-step-tree.tsx`,以及 `chat-message-row.tsx` 里的 `showReport` 分支。`AgentActivityStatus` 只保留「无 trace、无 timeline 的兜底」(onboarding 等地方若在用要先 grep),其 trace 分支删除。
4. **live 标记统一为 `InlineSpinner`,移除 `thinking-orbs`。** 决策见文末。`agent-activity-status.tsx``ThinkingOrb``InlineSpinner size={12}``package.json` 去依赖;`.conversation.is-rectification .agent-thinking-step / .agent-thinking-marker` 两条 14px 覆写删除。
5. **失败态与重新生成对齐。** 校正的「回答未完成」改为和普通会话一样走 composer notice`rectification-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.ts`started/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>` + 两个按钮换成 `ChatComposer``maxLength=500`,接近上限计数随之而来;`inputLabel` / `placeholder` 保持现有三段文案)。`ChatComposer` 目前从 `composer-draft` 外部 store 读草稿,校正面有自己的 `draft` state——给 `ChatComposer` 加一个可选 `value` prop(有值则受控,无值则读 store),不要让校正面写主会话的草稿 store。`tests/composer-isolation-contract.test.ts``composer-ime-contract` 会锁到,按红线 3。
2. 滚动跟随只留 `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` 滚动。
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.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(滚动跟随双实现)。
---
## 任务 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 180ms``Timeline label swap 120ms``Text 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 编号、未做与原因。