Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
21 KiB
PROGRESS · Agent 聊天流式体验与双会话面统一(2026-09-01)
工作树:.worktrees/streaming-ux-20260901
分支:codex/streaming-ux-20260901
基线:任务 0–2 时为 origin/staging @ fbb80fa3;2026-09-02 远端前进到 058e5db9(bf6989ec 第三批拆页、124d3990 引擎卫生、058e5db9 Home lint 修复)后本分支 rebase 到其上,任务 3 在该基线上做。
BUG 编号顺延:staging 的 lint 修复占用了 BUG-470,本分支四条整体后移(470→471、471→472、472→473、473→474),docs/BUG_HISTORY.md、本文件、四个测试文件里的注释与四个 commit message 一并改(用 git filter-branch --tree-filter/--msg-filter 在原提交上逐个改,再 rebase,只在 BUG_HISTORY.md 冲突一次)。任务书 TASK-chat-streaming-ux-20260901.md 未动。
未改 .gitea/workflows/**。未合并 staging,未碰 main。未手写 useCallback / useMemo(rectification-agentic-chat.tsx 既有的 useCallback 原样保留)。未动服务端事件语义;校正 thinking.delta 在 stream-mapping.ts 公开边界的丢弃原样保留。page.tsx 一行未动。
| 任务 | 状态 | commit | BUG |
|---|---|---|---|
| 0 · 流式按帧合并 + 匀速吐字 + Markdown 前缀记忆 | 完成 | dd4e3b37 |
BUG-473 |
| 1 · 结算不闪、时间线开合过渡、流式可折叠、queued 行 | 完成 | 69d19e0f |
BUG-474、BUG-475 |
| 2 · 校正接入同一套时间线、删三条死路径、移除 thinking-orbs | 完成 | cc17d971 |
BUG-476 |
| 3 · 校正复用 ChatComposer / 统一滚动跟随 / JumpToLatest / 900px | 完成(第三批合入后解除阻塞) | b72c1efc |
BUG-477、BUG-478 |
| 4 · DESIGN.md | 与 0/1/2 对应的条目随各任务改;与 3 对应的四条随任务 3 同 commit | — | — |
第二次 rebase 与编号再顺延(2026-09-02):远端又合入 1a3e14e7(问题题干显示/占用后解锁采用),占了 BUG-471/472。本分支再次用 filter-branch 在原提交上改号后 rebase 到 1a3e14e7:471→473、472→474、473→475、474→476、475→477、476→478,BUG_HISTORY、本文件、测试注释、commit message 一并改。冲突两处:docs/BUG_HISTORY.md(取 staging 全文 + 我方条目续在后面);rectification-agentic-chat.tsx(两边都保留——staging 的 collectSpokenPromptId 与题干 <p> 原样,staging 加在 <Textarea> 上的 aria-describedby 改为经 ChatComposer 新增的 describedBy prop 传入,composer 把它与字数计数 id 合并成一个 aria-describedby)。staging 新测试 rectification-spoken-collect 锁 aria-describedby={collectSpokenPrompt(内联 Textarea),按红线 3 改锁 describedBy={collectSpokenPrompt …} + composer 的合并表达式,注释原值。character-remaining-contract 的 aria-describedby={showRemaining ? remainingId : undefined} 锁随之改为合并表达式。
任务 0 · 流式渲染
改动:src/lib/stream-frame-buffer.ts(新)、src/lib/chat-markdown-split.ts(新)、src/components/chat-message-content.tsx、src/components/chat-message-row.tsx(streaming prop)、src/hooks/use-consultation-run.ts、src/components/rectification-agentic-chat.tsx。
- 两个会话面的事件回调都不再直接
setState:事件写入累加器,requestAnimationFrame每帧最多一次 flush;run.completed/run.failed/ abort / 读流结束时settle()同步冲干净;finally里dispose();校正attempt.reset走reset()。 - 释放节奏:
max(2, ceil(积压 ÷ 12))字/帧,积压按「新文本到达时的未释放量」记,所以 3,000 字突发恰好 12 帧追平(测试锁死),稳态约落后网络 12 帧(≈200ms);页面隐藏走 250mssetTimeout并一次放完。 - 重活只做一次:
parseAgentReply/applyThinkingSectionProgress只在 flush 里对已释放文本跑。 - Markdown:流式时在最后一个空行切「稳定前缀 / 尾块」,不切进代码围栏、同一列表的项之间、表格行之间;前缀交给
memo的StableMarkdownPrefix,只有尾块每帧 parse;切分对象是splitSpokenAnswerAndTechniqueAudit(text).spoken,技法审计表留在审计折叠里。结算后仍整篇一次 parse,DOM 结构与流式时一致(react-markdown 不加包裹元素)。 - 没有另写"薄 hook":
use-consultation-run按前两轮拆页公证是"内部 0 个 React hook",缓冲器按每次 send 创建/销毁的工厂用法更贴合,两处调用都是这个形态。
渲染次数探针(tests/home-streaming-render-split.test.ts 新增用例,假 rAF 驱动):200 个单字 token、每帧到 4 个、共 50 帧 → streamingRowRenders = 50(改前的架构每 token 一次 = 200)。tests/stream-frame-buffer.test.ts 同一场景 flush 50 次,且每次释放 ≥2 字、结算后与网络全文一致。
Performance 面板录制(3k 字回答无 >50ms 长任务)未做:无登录态、无 Chrome,见 BLOCKED.md。
任务 1 · 结算与时间线
改动:src/components/chat-transcript.tsx(重写)、src/lib/chat-message-view.ts(latestAssistantView)、src/lib/home-streaming-render-probe.ts(latestEntryMounts)、src/components/consultation-run-timeline.tsx(重写)、src/components/chat-message-row.tsx(GSAP 0.16)、src/app/globals.css、scripts/home-streaming-render-benchmark.mts(改用 LatestAssistantEntry)。
ChatTranscript=SettledMessageList excludeLatestAssistant+ 一个LatestAssistantEntry key={renderKey}。同一个MessageEntry渲染历史行、流式行、刚结算行,动作条与追问只在 settled 出现。renderKey在流式(message-N,N=历史条数)与结算(末条下标 N)下相同,纯函数latestAssistantView有测试锁。.message的 CSSmessage-enter关键帧删除,只剩 GSAP 一份,时长 0.18 → 0.16(DESIGN.md §6 = 160ms)。- 时间线:去掉
key={live…};外层<section>+button[aria-expanded][aria-controls];open = userOpen ?? live;折叠是grid-template-rows 0fr↔1fr180ms,折叠后内容inert;summary 文案换行 120ms 淡入;live 行与 summary 文案共用既有agent-activity-status__textshimmer;rows为空且 live 时渲染QUEUED_TIMELINE_ROW「正在处理…」。reduced-motion 下三处过渡为 0(全局*规则之外再显式写了一次)。 latestEntryMounts探针只能在浏览器里手动读(无 DOM 测试库,renderToString不跑 effect),见BLOCKED.md。
任务 2 · 双会话面统一
改动:src/lib/rectification-timeline-adapter.ts(新)、src/components/rectification-agentic-chat.tsx、src/components/agent-activity-status.tsx(重写为兜底)、src/components/chat-message-row.tsx、src/app/globals.css、package.json / package-lock.json(npm uninstall thinking-orbs);删除 consultation-thinking-report.tsx、thinking-step-tree.tsx、completed-activity-receipt.tsx。
- 适配:tool trace →
calculate行(started=live / completed=done,标签沿用RECTIFICATION_TOOL_*_LABELS);failedTool的行标签加「未完成」且不挂 chips;回执methods经PUBLIC_RECTIFICATION_METHOD_LABELS去重 ≤8 作为最后一条完成 calculate 行的sources;没有 live 行且activity.label以「正在」开头时补一条 live 行(answer-composition→write,其余 →calculate);think 类 trace 项映射为 think 行(现网不会出现,服务端丢弃)。 - 校正结算后与普通会话一样收成「已完成 N 步」一行,方法 chips 在行内展开;第二个回执折叠块、消息上方红字失败行、重新生成时的假「正在组织回答…」全部删除;失败走既有
error通知;重新生成显示 queued 行。 chat-message-row.tsx只剩 timeline 一条主路径 +AgentActivityStatus兜底(无 trace 分支、无 orb);live 标记全部InlineSpinner;.conversation.is-rectification .agent-thinking-step/.agent-thinking-marker14px 覆写删除。- 校正的「已用时 N 秒」计时(
ActivityElapsed)随 trace 面板一起退出——时间线本来就没有这个元素,两面一致优先。
任务 3 · 校正复用 ChatComposer、统一滚动跟随与「跳到最新」
开工时 git log origin/staging -- frontend/src/app/page.tsx 最新为 551d6317,第三批未合入,先按红线 7 登记 BLOCKED.md;远端合入 bf6989ec/058e5db9 后 rebase,阻塞解除,BLOCKED.md 该条改为已完成。
改动:src/hooks/use-conversation-scroll-anchor.ts、src/components/jump-to-latest-button.tsx(新)、src/components/chat-composer.tsx、src/app/page.tsx、src/components/rectification-agentic-chat.tsx、src/app/globals.css;删除 src/lib/rectification-sticky-scroll.ts。
- ChatComposer:新增可选
value(受控)与remainingId。const storeDraft = useComposerDraft(); const draft = value ?? storeDraft;——store 订阅仍在组件内,主会话行为不变;校正面传value={draft},grep composer-draft在校正文件里为 0(契约锁死)。校正面maxLength=500(RECTIFICATION_COMPOSER_MAX_LENGTH,与主对话同上限),footer 放CharacterRemaining(id 用useId);三段 placeholder、Enter/Shift+Enter/isComposing、「停止回答」文案与stopRun原样。 - 滚动跟随只留一份:
useConversationScrollAnchor增加 follow:ResizeObserver观察滚动容器的子元素(MutationObserver childList跟踪子元素增删并重新观察),anchored 时下一帧scrollTop = scrollHeight;resetKey变化时先落底一次;anchored经anchoredRef在 effect 里同步(不在 render 写 ref,过react-hooks规则)。page.tsx那个按activeStreamingText触发、结算时再补 smooth 的 effect 删除;hook 调用签名(conversation, !rectificationSurfaceOpen && !starterHomeVisible, activeSessionId)不变,starter home 不滚动的性质由active=false承担。校正面useConversationScrollAnchor(conversation, true, caseId),删除followTailRef/scrollFrameRef/updateFollowState/scrollToLatest/followLatestContent/showJumpToLatest/choiceCardsOpen与两个useLayoutEffect;rectification-sticky-scroll.ts删除。选择卡 360px 带宽的特例随之退出:卡片本来就在 transcript 里,看着它的人离底 ≤96px,本就 anchored。 - JumpToLatestButton:
.jump-to-latest(absolute、bottom: 100%、pointer-events: none)+.jump-to-latest__button(44px、--shadow-elevated、focus ring、hover 暖卡片色),两面共用,文案统一「跳到最新」带ArrowDown;page.tsx内联 Tailwind 版(shadow-md)与.rectification-jump-latest三条规则删除。 - 宽度:
.rectification-workspace__chat .message-list { max-width: 720px }删除,两面同.message-list的 900px;--rectification-jump-clearance底部留白保留。 - 手工项(两面各做一次「流式中上滚→停跟随→出现按钮→点回底并恢复跟随」)未做,无登录态无 Chrome;行为由
chat-notice-and-scroll-contract、rectification-agentic-entry、rectification-answer-choice的 hook/组件锁与nextAnchorState纯函数断言代替。
任务 4 · DESIGN.md
已改:§3 思考正文 13px 写成显式例外;§5 Message 正文 16px → 17px(≤767px 时 16px,与 globals.css 一致)、Motion 改为"只有 GSAP 一份 160ms、结算不重放"、新增「Streaming states」七态表(含 failed 不用消息内红字、pacing 公式、reduced-motion 行为、校正只差不显示思考正文);§6 表新增 Thinking collapse 180ms / Timeline label swap 120ms / Text reveal;§9 等待表把时间线 live 步写成两面共用,注明 live 标记只有 InlineSpinner、orb 已移除。
随任务 3 已改:§4 阅读宽度写明两面同 900px;§5 Input and composer 加「One composer」条;§5 新增「Jump to latest」条目;§7 写明 --shadow-soft 的实际归属(叙事卡、会话内 composer 字段、starter 主题卡 focus、auth 模式 tab)与「浮动控件一律 --shadow-elevated、不得用工具类阴影」。
被触碰的既有断言(红线 3,每处源码上方均有原值与错因注释)
| 文件 | 原断言 | 现在 | 理由 |
|---|---|---|---|
tests/home-streaming-render-split.test.ts |
split.streamingRowRenders === tokens.length |
≤ tokens.length && ≥ 1,另加按帧驱动的新用例 |
原值是"每 token 一次提交"的现状快照(BUG-473) |
tests/chat-bundle-splitting-contract.test.ts |
duration: 0\.18 |
0\.16 |
DESIGN.md §6 160ms,0.18 是 GSAP 副本漂移(BUG-474) |
tests/session-conversation-layout.test.ts |
duration:\s*0\.18 |
0\.16 |
同上 |
tests/class-name-definition-contract.test.ts |
knownUnstyled 含 consultation-run-timeline__summary、consultation-step-tree |
两条移除 | 前者现在有样式(按钮),后者类名已随组件删除;该测试本身要求列表无过期项 |
tests/chat-bundle-splitting-contract.test.ts |
"the thinking orb loads on demand…"(OrbState 导入、dynamic(...)、20px 占位、prefetchOnIdle(importThinkingOrb)) |
改为"live 标记是共享 InlineSpinner,无 orb chunk" | orb 是第二种 live 标记且被 14px 裁切(BUG-476) |
tests/chat-stream-layout.test.ts |
reportSource/treeSource 读取;<ThinkingOrb … size={20};ConsultationThinkingReport、hasTrace、activityTrace={message.activityTrace}、activityTrace ?? []、function TraceStep、.agent-thinking-step.is-think:has(...)、report/tree 内容锁、consultation-step-tree__* CSS 锁、.consultation-thinking-report .message-thinking |
改为锁"只剩一条路径"(doesNotMatch 这些符号)+ InlineSpinner size={12} |
锁的正是被删除的第二/第三条思考渲染路径(BUG-476) |
tests/rectification-agentic-entry.test.ts |
读取 completed-activity-receipt.tsx 并锁其 <details>/不 open/「本轮完成 · N 个步骤」;activityStatus /activityTrace/ |
改为锁 chat /rectificationTimelineRows/、chat /completedReceipt/、activityStatus 不含 activityTrace |
锁的是校正专属第二折叠块与 trace 面板(BUG-476) |
tests/rectification-activity-receipt.test.ts |
deltaBranch 内 state: raw.trim() ? "streaming" : "thinking"、text: raw,;message.state === "settled" && message.failed |
状态派生改锁 frame flush 段(state: text.trim() ? …、text,、frames.setAnswer(raw));失败行改锁 && !message.failed 且无 rectification-activity-failure |
前者锁的是每 chunk 一次 setMessages(BUG-473),后者锁的是校正专属红字失败行(BUG-476)。注:这个文件在任务 0 的聚焦跑里漏跑了,任务 2 跑全量前才发现,改在 cc17d971 |
任务 3 触碰的断言(同样每处有原值与错因注释):
| 文件 | 原断言 | 现在 | 理由 |
|---|---|---|---|
tests/character-remaining-contract.test.ts |
aria-describedby={showRemaining ? composerRemainingId : undefined} |
remainingId = composerRemainingId 默认 + aria-describedby={showRemaining ? remainingId : undefined} |
composer 现为两面共用,校正传自己的计数 id(BUG-477) |
tests/composer-isolation-contract.test.ts |
const draft = useComposerDraft() |
const storeDraft = useComposerDraft(); const draft = value ?? storeDraft; + 校正传 value={draft} 且不 import composer-draft |
受控 value 是任务书要求,隔离性质加强而非减弱(BUG-477) |
tests/chat-navigation-a11y-contract.test.ts |
内联按钮的 min-h-11 / focus-visible:ring-3 / pointer-events-auto / <ArrowDown … className="size-4" |
页面锁 <JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />,语义改锁组件源码 + .jump-to-latest__button 的 44px / focus-visible / pointer-events |
锁的是内联 Tailwind 按钮本身(BUG-478) |
tests/chat-notice-and-scroll-contract.test.ts |
三处:page effect 内 anchored 守卫先于 container.scrollTo;jump control 的 Tailwind 类;absolute inset-x-0 bottom-full |
follow 在 hook 内、anchoredRef 守卫先于 scrollTop 赋值、page.tsx 无 scrollTo、ResizeObserver/MutationObserver 存在;按钮改锁组件与 CSS;定位改锁 .jump-to-latest { position: absolute; bottom: 100% } |
锁的是每 token 一次 scrollTo 的 effect 与内联按钮(BUG-478) |
tests/chat-stream-layout.test.ts |
composerWrap 含 pointer-events-none absolute inset-x-0 bottom-full |
含 <JumpToLatestButton + CSS 锁 |
同上 |
tests/starter-questions.test.ts |
page effect 以 if (starterHomeVisible) return 开头并调 scrollTo |
hook 调用第二参含 !starterHomeVisible,follow effect if (!active || !element) return 先于赋值,page 无 scrollTo |
性质(starter home 不自动滚)由 active 承担(BUG-478)。这条在任务 3 第一次全量跑才暴露,改在最后的 docs commit |
tests/rectification-agentic-entry.test.ts |
整段校正专属跟随锁(followTailRef、isNearBottom、shouldShowJumpToLatest、choiceCardsOpen、回到最新、.rectification-jump-latest);className="composer-stop"、aria-label="停止回答";<form className="composer" |
锁 useConversationScrollAnchor(conversation, true, caseId)、<JumpToLatestButton …>、这些符号不再出现、.jump-to-latest 居中;stopLabel="停止回答"、onStop={stopRun}、composer-stop 在 chat-composer.tsx;<ChatComposer + RECTIFICATION_COMPOSER_MAX_LENGTH = 500 + <CharacterRemaining |
锁的是第二套滚动与第二个输入框(BUG-477/476) |
tests/rectification-answer-choice.test.ts |
import rectification-sticky-scroll 并断言 isNearBottom/shouldFollowLatest/shouldShowJumpToLatest(含 360px 选择卡带宽);回到最新、followTailRef.current |
import hook 的 conversationAnchorThreshold/nextAnchorState,断言 96px 与同一组距离判定;锁 <JumpToLatestButton …> 与 conversationAnchor.anchored |
模块已删除(BUG-478) |
新增测试:tests/stream-frame-buffer.test.ts(7)、tests/chat-markdown-split.test.ts(4)、tests/chat-stream-settle-contract.test.ts(4)、tests/rectification-timeline-adapter.test.ts(6)、home-streaming-render-split +1。
命令输出
./node_modules/.bin/tsc --noEmit → 0(任务 0/1/2 各一次;rebase 后一次;任务 3 后一次,均无诊断)。
./node_modules/.bin/eslint 触及文件 → 0 error。warning 只落在 page.tsx(43 条,与 origin/staging 同文件同数)与 use-consultation-run.ts(9 条既有 no-unused-vars,本轮只加一行 import);本轮新建的六个文件 0 warning。
./node_modules/.bin/tsx --test tests/*.test.ts(基线在干净的 origin/staging @ 058e5db9 独立 worktree 里 npm ci 后跑):
旧基线 fbb80fa3 |
基线 058e5db9 |
任务 3 后(058e5db9 上) |
最终(rebase 到 1a3e14e7 后,HEAD 树) |
|
|---|---|---|---|---|
| tests | 2460 | 2461 | 2482 | 2484 |
| pass | 2426 | 2427 | 2448 | 2450 |
| fail | 24 | 24 | 24 | 24 |
| skipped | 10 | 10 | 10 | 10 |
1a3e14e7 本身只改了校正类测试文件(+2 用例),没有另跑一份干净基线;最终列的 24 条失败清单与 058e5db9 基线逐条比对差集为空,仍是同一组环境缺口。
24 条失败与新基线逐条比对完全同一集合(comm 差集为空):全部是本机无 Docker / PostgreSQL / 部署夹具的数据库、Better Auth、staging workflow YAML、live sync 类(admin code functions、Owner recovery、local PostgreSQL、redeem security、personal report job/sections RLS、migration runner、v9 migration 等)。10 条 skipped 同基线。无产品逻辑新增失败。任务 3 第一次全量跑曾多出 1 条 starter-questions 的源码锁失败,按红线 3 改锁 hook 后(见上表)复跑归零。
rebase 后、任务 3 前的聚焦 14 套件 117/117;任务 3 后的聚焦 17 套件 177/177。
./node_modules/.bin/next build → 0。✓ Compiled successfully in 22.6s、Finished TypeScript in 28.4s、Generating static pages (62/62),/ 仍是 ○ (Static)。5 条既有 skill-package-registry.ts filesystem-tracing warning,未失败。
未做与原因
见 BLOCKED.md 顶部:任务 3 已解除并完成;仍未做的是浏览器级手工验收(Performance 录制、结算录屏、两面并排截图、流式期间折叠、两面滚动跟随实机)——无登录态无 Chrome;latestEntryMounts 探针无 DOM 测试库不可驱动。