# TASK · 校正会话流式时整条对话每帧重建,已结算消息每帧重跑 Markdown
- 日期:2026-09-15
- 基线 commit:`origin/staging` @ `6b3248bf`
- 执行分支:`codex/rectification-settled-render-split-20260915`
- 独占文件:`frontend/src/components/rectification-agentic-chat.tsx`、新建的行组件文件、`frontend/src/components/chat-message-row.tsx`、`frontend/tests/` 下新增用例
- 与本日其它三单**无文件重叠**,可并行
- 规模:抽一个行组件 + 把逐消息派生搬进去 + 稳住回调身份。**不改任何交互行为、不改任何文案。**
---
## 1. 事故实证
`frontend/src/components/rectification-agentic-chat.tsx`:1 973 行,`useState` 14 个,`useCallback` 12 个,**`useMemo` 0 个,`memo` 0 个**。
消息列表是直接内联在组件体里的 `messages.map((message) => {...})`(在 `
` 内),而且 map 体里每条消息都要做非平凡的派生:
- `{...message}` 展开出一个新的 `displayedMessage`
- `rectificationTimelineRows({ trace, receipt, activity, settled })` —— 每次都新建数组
- `vargaSentenceFromMethods(message.completedReceipt?.methods)`
- `choiceCardFromQuestion(question, ...)` —— 每次都新建对象
- 为带题的消息构造 `afterAnswer` 整棵 JSX(含 `RectificationChoiceCard`)
渲染出的 `ChatMessageRow`(`chat-message-row.tsx`)**没有 `memo`**,它内部的 `ChatMessageContent` 对**已结算**消息走的是 `renderProse(spoken, renderMarkdown)` 这条没有任何记忆化的分支(`StableMarkdownPrefix` 的 memo 只覆盖流式那一条)。`ChatMessageActions` 的 `onFeedback` / `onCopy` / `onRegenerate` 全是内联箭头函数,`submitChoice` / `submitStop` / `copyMessage` / `regenerateMessage` 也不在那 12 个 `useCallback` 里,每次渲染都是新身份。
流式输出这一侧是对的:`createStreamFrameBuffer` 已经把每个 `answer.delta` 合并成**每个动画帧最多提交一次**(BUG-473 的成果,该单影响面里就写着本文件)。但每一帧的那一次 `setMessages(current => current.map(...))` 会重渲整个列表 —— 于是每秒约 60 次,把整条会话里**每一条早已结算的消息**连同它的 Markdown 全部重算一遍。
校正会话天生就长:采集 + 探针 + 交付,二三十轮很常见。会话越长越卡,而且卡在最不该卡的时候——正在出结果的那一轮。
## 2. 根因
BUG-473 修的是「每个网络事件都提交一次」,它在本文件里落地了 `stream-frame-buffer`。但同一单在**咨询面**还做了第二件事:`chat-transcript.tsx` 把「已结算的历史」和「正在流的那一条」拆成两个 `memo` 组件(`SettledMessageList` + `HistoryMessageEntry`,以及 `LatestAssistantEntry`),历史整体只渲染一次。
**校正面只继承了前一半。** BUG-473 的两条防复发(「流式状态必须经 `stream-frame-buffer` 提交」「流式期间的 Markdown 渲染必须走前缀/尾块切分」)都只约束**正在流的那一行**,没有一条要求给**已结算的行**留记忆化边界,所以这半边缺失没有被任何断言拦住;`home-streaming-render-split.test.ts` 也只覆盖咨询面的那几个组件。
同类前史:BUG-249(`Home()` 2 723 行、`useCallback` 与 `useMemo` 各为 0,每次击键重渲整个组件)。同一个形态,换了个组件。
## 3. 决策记录
产品 2026-09-15 授权本单,口径:
1. **纯性能重构,零行为变化。** 交付后校正会话的每一个可见行为——消息顺序、题目嵌入位置、选择卡可点性、跳过提示、交付卡出现时机、重新生成、复制、点赞点踩、停止——必须与改前逐字一致。本单不修任何已知交互缺陷,发现了写进进度记录。
2. **不改文案、不改动效、不改间距。** 因此本轮**不需要**更新 `frontend/DESIGN.md`(AGENTS §7.5 的触发条件是「改 UI」,纯记忆化不算)。若执行中确实动了任何可见样式,则必须同提交更新 `DESIGN.md`。
3. **不启用 React Compiler。** BUG-260 的结论是它在本仓对目标组件静默放弃且无法证明生效;本单用显式 `memo` 解决,不碰 `next.config.ts`。
4. **不做「把流式文本从 `messages` 里搬出去」的大改。** 咨询面走的是 `streamingText` 独立 state 的路子,校正面把流式文本写在 `messages` 里。改成前者是更彻底的方案,但会动到快照回填、重新生成、题目绑定等一大片逻辑,风险与本单不匹配。**逐行 `memo` 就够**:`.map` 每帧产生新数组不影响 memo,因为 memo 比的是**每一行自己的 props**,而已结算消息的对象身份在流式期间是稳定的(每帧只有直播那一行的对象被替换)。
## 4. 硬红线
1. `frontend/src/app/page.tsx` 一行不许动(1951/2000,AGENTS §6)。
2. 不得新写第二个聊天输入框、第二套滚动跟随、第二套加载动画(AGENTS §6)。滚动锚点仍是 `useConversationScrollAnchor` + `JumpToLatestButton`,本单不得改它们的调用方式。
3. 测试总数不得低于开工时 `origin/staging` 的实测;改任何既有断言必须写「原值 / 新值 / 原因」三栏(AGENTS §7.3)。
4. `next build` 后 `/` 仍须 `○ Static`,首屏 JS gzip 变化在 ±2 % 内(上次实测 584 413 B)。
5. 不得用 `React.lazy` + `Suspense` 承载 Markdown(BUG-250 的结论:promise 落定后仍需一次重渲,可能露出一帧 fallback)。
6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
## 5. 任务分解
### 5.1 抽出逐消息行组件并加 `memo`
把 `messages.map` 的整个 body 搬进一个新文件里的 `memo` 组件(建议 `rectification-message-entry.tsx` → `RectificationMessageEntry`),§1 列的那五项派生全部搬进组件内部计算,不再由容器每帧算好再传下去。
组件的 props 只允许:
- `message`(已结算行在流式期间身份稳定)
- 标量:`busy`、`readonly`、`regenerating`、`canRegenerate`、`liveQuestion` 所依赖的那几个 id 与布尔、`choiceNonce`、`savedTime`、`copied`
- 该行自己的 `feedback` 值(**不是整张 feedback map**)
- 一个 `actionsRef`(见 5.2)
不允许把每帧新建的对象/数组直接当 props 传下去(时间轴行、choice card、`afterAnswer` JSX 都属此列)。
- 验收:源码合同断言 `rectification-agentic-chat.tsx` 里不再直接调用 `rectificationTimelineRows` / `vargaSentenceFromMethods` / `choiceCardFromQuestion`。
- 验收:源码合同断言新组件由 `memo(` 包裹。
### 5.2 用 `actionsRef` 稳住回调身份
照搬 `chat-transcript.tsx` 已经在用的模式:把 `submitChoice`、`submitStop`、`copyMessage`、`regenerateMessage`、feedback 切换等放进一个 ref 容器,每帧只更新 ref 的 `.current`,组件内部通过 `actionsRef.current.xxx(...)` 调用。不要逐个加 `useCallback` 去追依赖数组——那条路在本文件的 state 规模下一定会漏。
- 验收:源码合同断言新组件的 props 里没有任何函数类型字段(`actionsRef` 除外)。
### 5.3 给 `ChatMessageRow` 加 `memo`
`chat-message-row.tsx` 的 `ChatMessageRow` 目前是裸函数。加 `memo` 后咨询面同样受益。注意它的 `afterAnswer` 是 `ReactNode` prop——由 5.1 保证它在新组件内部构造,从而与该行自身的 props 同源。
- 验收:咨询面既有测试(`home-streaming-render-split.test.ts`、`chat-markdown-split.test.ts` 等)全绿,无断言改动。
### 5.4 结算态 Markdown 也要记忆化
`chat-message-content.tsx` 里 `streaming === false` 走的 `renderProse(spoken, renderMarkdown)` 没有任何缓存。给结算态也套一层按文本内容记忆的组件(`StableMarkdownPrefix` 已经是这个形状,可直接复用或抽成共用组件)。这一条独立于 5.1,即使行组件的 memo 因为某个 prop 抖动而失效,它也能兜住最贵的那部分。
- 验收:新增断言——同一段文本连续渲染两次,Markdown 解析器只被调用一次(用可计数的桩 renderer)。
### 5.5 渲染次数回归测试
照 `frontend/tests/home-streaming-render-split.test.ts` 的手法,为校正面补一份:复用 `src/lib/home-streaming-render-probe.ts` 的计数器(在新组件里调 `noteSettledRowRender()`),按帧手工驱动渲染,断言**已结算行的渲染次数不随流式帧数增长**。
必须照搬那份测试已经做对的两件事:
- 由测试手工驱动渲染次数,不假装 `renderToString` 能反映真实 memo 命中(本仓测试是字符串渲染,数不了重渲染次数——BUG-617 记录里已经写过这条);
- 同时跑一份「未拆分」对照,断言拆分后的计数**严格小于**对照,证明这个取证方法不是恒为真。
- 验收:新测试在改动前跑是红的、改动后是绿的(执行方需在进度记录里贴出这两次运行结果)。
### 5.6 Bug 历史
同一变更内写进 `docs/BUG_HISTORY.md`,预占 **BUG-725**。必须写明:**关联 BUG-473**(它的影响面包含本文件,但两条防复发都只约束正在流的那一行),以及 BUG-249(同形态前史)。防复发升级为:**任何流式会话面都必须为已结算消息保留记忆化边界,并由一条按帧驱动的渲染计数断言钉死;新增会话面必须同时补这条断言。**
## 6. 让步顺序
1. 5.4(结算态 Markdown 记忆化)最先做——最小、最独立,单独就能拿走最贵的一块。
2. 5.1 + 5.2 是主体,必须一起做(只加 `memo` 不稳住回调身份等于没加)。
3. 5.3 可以砍,砍了在进度记录里写明。
4. 5.5 **不得砍**——没有渲染计数断言的性能改动一律视为未通过(这正是本次缺口能存在这么久的原因)。
5. 5.6 不得砍。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-settled-render-split-20260915 \
.worktrees/rectification-settled-render-split-20260915 origin/staging
cd .worktrees/rectification-settled-render-split-20260915/frontend
git status -sb | head -1
npm ci
```
验收命令:
```bash
./node_modules/.bin/tsc --noEmit
npm run lint # 0 error
npx tsx --test tests/rectification-*.test.ts tests/home-streaming-render-split.test.ts \
tests/chat-markdown-split.test.ts tests/stream-frame-buffer.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单(无 Docker 时数据库套件照常红)
npm run build # `/` 仍须 ○ Static
```
## 8. 真人验收欠账
本仓没有浏览器环境,「长会话流式时是否还卡」只能由产品在真实环境确认。执行方必须在 `docs/testing/` 下留一份可照做的条目,至少包含:开一个已有二十轮以上的校正会话 → 发一条会触发长回答的消息 → 观察流式期间页面是否还有明显掉帧、历史消息是否闪烁。
## 9. BUG 编号起点
基线 `6b3248bf` 上最大号 **BUG-720**。本单预占 **BUG-725**。同日四单并行(721 / 722–724 / 725 / 726),开工时核对实际最大号,冲突顺延并在进度记录写明。
## 10. 不在本单范围
- 把流式文本从 `messages` 搬进独立 state(§3.4 已否决,风险不匹配)
- React Compiler(§3.3)
- 首屏包体积(584 KB gzip 是全站壳的问题,另议)
- 校正面任何交互缺陷(本单零行为变化)