From fbb80fa376ac416afccb942e4ff4e8035898df32 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 21:13:45 +0000 Subject: [PATCH] docs(chat): add streaming-ux and dual-surface unification brief Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- TASK-chat-streaming-ux-20260901.md | 199 +++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 TASK-chat-streaming-ux-20260901.md diff --git a/TASK-chat-streaming-ux-20260901.md b/TASK-chat-streaming-ux-20260901.md new file mode 100644 index 00000000..e9615d85 --- /dev/null +++ b/TASK-chat-streaming-ux-20260901.md @@ -0,0 +1,199 @@ +# 任务书 · 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 都是独立 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.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` 在新挂载的 `
` 上**重放 GSAP 入场动画**(autoAlpha 0→1、y 12→0),用户正在读的回答整体闪一次。`tests/chat-stream-layout.test.ts` 第一条"keeps the assistant render identity stable"想锁的正是这件事,但只锁了 `renderKey` 字符串,没锁组件身份。 +- `consultation-run-timeline.tsx`:`
`,结算时**整个 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`(`
`,结算收成「已完成 N 步」一行) | `AgentActivityStatus` trace 路径(`
    ` 永远全展开,**结算后不折叠**)+ 第二个 `
    ` `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) | 裸 `