Files
Jyotisha/PROGRESS-chat-streaming-ux-20260901.md
T
2026-09-02 04:29:01 +00:00

21 KiB
Raw Blame History

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

工作树:.worktrees/streaming-ux-20260901 分支:codex/streaming-ux-20260901 基线:任务 02 时为 origin/staging @ fbb80fa32026-09-02 远端前进到 058e5db9bf6989ec 第三批拆页、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 / useMemorectification-agentic-chat.tsx 既有的 useCallback 原样保留)。未动服务端事件语义;校正 thinking.deltastream-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 到 1a3e14e7471→473、472→474、473→475、474→476、475→477、476→478BUG_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-collectaria-describedby={collectSpokenPrompt(内联 Textarea),按红线 3 改锁 describedBy={collectSpokenPrompt …} + composer 的合并表达式,注释原值。character-remaining-contractaria-describedby={showRemaining ? remainingId : undefined} 锁随之改为合并表达式。

任务 0 · 流式渲染

改动:src/lib/stream-frame-buffer.ts(新)、src/lib/chat-markdown-split.ts(新)、src/components/chat-message-content.tsxsrc/components/chat-message-row.tsxstreaming prop)、src/hooks/use-consultation-run.tssrc/components/rectification-agentic-chat.tsx

  • 两个会话面的事件回调都不再直接 setState:事件写入累加器,requestAnimationFrame 每帧最多一次 flushrun.completed / run.failed / abort / 读流结束时 settle() 同步冲干净;finallydispose();校正 attempt.resetreset()
  • 释放节奏:max(2, ceil(积压 ÷ 12)) 字/帧,积压按「新文本到达时的未释放量」记,所以 3,000 字突发恰好 12 帧追平(测试锁死),稳态约落后网络 12 帧(≈200ms);页面隐藏走 250ms setTimeout 并一次放完。
  • 重活只做一次:parseAgentReply / applyThinkingSectionProgress 只在 flush 里对已释放文本跑。
  • Markdown:流式时在最后一个空行切「稳定前缀 / 尾块」,不切进代码围栏、同一列表的项之间、表格行之间;前缀交给 memoStableMarkdownPrefix,只有尾块每帧 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.tslatestAssistantView)、src/lib/home-streaming-render-probe.tslatestEntryMounts)、src/components/consultation-run-timeline.tsx(重写)、src/components/chat-message-row.tsxGSAP 0.16)、src/app/globals.cssscripts/home-streaming-render-benchmark.mts(改用 LatestAssistantEntry)。

  • ChatTranscript = SettledMessageList excludeLatestAssistant + 一个 LatestAssistantEntry key={renderKey}。同一个 MessageEntry 渲染历史行、流式行、刚结算行,动作条与追问只在 settled 出现。renderKey 在流式(message-N,N=历史条数)与结算(末条下标 N)下相同,纯函数 latestAssistantView 有测试锁。
  • .message 的 CSS message-enter 关键帧删除,只剩 GSAP 一份,时长 0.18 → 0.16DESIGN.md §6 = 160ms)。
  • 时间线:去掉 key={live…};外层 <section> + button[aria-expanded][aria-controls]open = userOpen ?? live;折叠是 grid-template-rows 0fr↔1fr 180ms,折叠后内容 inertsummary 文案换行 120ms 淡入;live 行与 summary 文案共用既有 agent-activity-status__text shimmerrows 为空且 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.tsxsrc/components/agent-activity-status.tsx(重写为兜底)、src/components/chat-message-row.tsxsrc/app/globals.csspackage.json / package-lock.jsonnpm uninstall thinking-orbs);删除 consultation-thinking-report.tsxthinking-step-tree.tsxcompleted-activity-receipt.tsx

  • 适配:tool trace → calculate 行(started=live / completed=done,标签沿用 RECTIFICATION_TOOL_*_LABELS);failedTool 的行标签加「未完成」且不挂 chips;回执 methodsPUBLIC_RECTIFICATION_METHOD_LABELS 去重 ≤8 作为最后一条完成 calculate 行的 sources;没有 live 行且 activity.label 以「正在」开头时补一条 live 行(answer-compositionwrite,其余 → 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-marker 14px 覆写删除。
  • 校正的「已用时 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.tssrc/components/jump-to-latest-button.tsx(新)、src/components/chat-composer.tsxsrc/app/page.tsxsrc/components/rectification-agentic-chat.tsxsrc/app/globals.css;删除 src/lib/rectification-sticky-scroll.ts

  • ChatComposer:新增可选 value(受控)与 remainingIdconst storeDraft = useComposerDraft(); const draft = value ?? storeDraft;——store 订阅仍在组件内,主会话行为不变;校正面传 value={draft}grep composer-draft 在校正文件里为 0(契约锁死)。校正面 maxLength=500RECTIFICATION_COMPOSER_MAX_LENGTH,与主对话同上限),footer 放 CharacterRemainingid 用 useId);三段 placeholder、Enter/Shift+Enter/isComposing、「停止回答」文案与 stopRun 原样。
  • 滚动跟随只留一份useConversationScrollAnchor 增加 followResizeObserver 观察滚动容器的子元素(MutationObserver childList 跟踪子元素增删并重新观察),anchored 时下一帧 scrollTop = scrollHeightresetKey 变化时先落底一次;anchoredanchoredRef 在 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 与两个 useLayoutEffectrectification-sticky-scroll.ts 删除。选择卡 360px 带宽的特例随之退出:卡片本来就在 transcript 里,看着它的人离底 ≤96px,本就 anchored。
  • JumpToLatestButton.jump-to-latestabsolute、bottom: 100%pointer-events: none+ .jump-to-latest__button44px、--shadow-elevated、focus ring、hover 暖卡片色),两面共用,文案统一「跳到最新」带 ArrowDownpage.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-contractrectification-agentic-entryrectification-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 160ms0.18 是 GSAP 副本漂移(BUG-474
tests/session-conversation-layout.test.ts duration:\s*0\.18 0\.16 同上
tests/class-name-definition-contract.test.ts knownUnstyledconsultation-run-timeline__summaryconsultation-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}ConsultationThinkingReporthasTraceactivityTrace={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 deltaBranchstate: 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 一次 setMessagesBUG-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.scrollTojump control 的 Tailwind 类;absolute inset-x-0 bottom-full follow 在 hook 内、anchoredRef 守卫先于 scrollTop 赋值、page.tsxscrollToResizeObserver/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 调用第二参含 !starterHomeVisiblefollow effect if (!active || !element) return 先于赋值,page 无 scrollTo 性质(starter home 不自动滚)由 active 承担(BUG-478)。这条在任务 3 第一次全量跑才暴露,改在最后的 docs commit
tests/rectification-agentic-entry.test.ts 整段校正专属跟随锁(followTailRefisNearBottomshouldShowJumpToLatestchoiceCardsOpen回到最新.rectification-jump-latest);className="composer-stop"aria-label="停止回答"<form className="composer" useConversationScrollAnchor(conversation, true, caseId)<JumpToLatestButton …>、这些符号不再出现、.jump-to-latest 居中;stopLabel="停止回答"onStop={stopRun}composer-stopchat-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.ts7)、tests/chat-markdown-split.test.ts4)、tests/chat-stream-settle-contract.test.ts4)、tests/rectification-timeline-adapter.test.ts6)、home-streaming-render-split +1。

命令输出

./node_modules/.bin/tsc --noEmit0(任务 0/1/2 各一次;rebase 后一次;任务 3 后一次,均无诊断)。

./node_modules/.bin/eslint 触及文件 → 0 error。warning 只落在 page.tsx43 条,与 origin/staging 同文件同数)与 use-consultation-run.ts9 条既有 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 build0✓ Compiled successfully in 22.6sFinished TypeScript in 28.4sGenerating static pages (62/62)/ 仍是 ○ (Static)。5 条既有 skill-package-registry.ts filesystem-tracing warning,未失败。

未做与原因

BLOCKED.md 顶部:任务 3 已解除并完成;仍未做的是浏览器级手工验收(Performance 录制、结算录屏、两面并排截图、流式期间折叠、两面滚动跟随实机)——无登录态无 Chrome;latestEntryMounts 探针无 DOM 测试库不可驱动。