style/优化聊天历史页 #590

Merged
k88936 merged 27 commits from feat/smoother-dialog-history into master 2026-10-02 23:22:32 +08:00
Member

close #510

优化聊天历史:

  • 自动加载历史对话
    shotmd-1790935435.jpg
  • 回到底部按钮
    shotmd-1790935475.jpg
    shotmd-1790935454.jpg
  • 展开比如工具调用时伴随滑动
    shotmd-1790937994-compressed.webp
close #510 优化聊天历史: * 自动加载历史对话 ![shotmd-1790935435.jpg](/attachments/2a8bbbed-79ec-4fa1-8978-c4e7a49b0fdd) * 回到底部按钮 ![shotmd-1790935475.jpg](/attachments/62dc6cf8-e3c9-43e6-8bf4-690d4de598f3) ![shotmd-1790935454.jpg](/attachments/c3e785b0-ed71-4156-b7ab-ee5672300e2b) * 展开比如工具调用时伴随滑动 ![shotmd-1790937994-compressed.webp](/attachments/8926625c-52a0-4e77-8382-f84d3fca8be8)
k88936 added 10 commits 2026-10-02 18:54:04 +08:00
新增 ADR,记录更早历史自动加载、内联加载/错误行、回合 key 锚点前插、回到底部胶囊与折叠展开的滚动契约。

在旧「对话历史单一事实源」ADR 的分页条目上补充指向新 ADR 的引用,保留原分页锚点口径。

docs/README.md 登记新 ADR。

Co-authored-by: Junie <junie@jetbrains.com>
新增 conversationScrollPolicy.ts:滚动/自动加载阈值、加载门、胶囊文案与填充视口判据集中在一处。
新增 conversationScrollAnchor.ts:回合 key + 块序号 + 偏移的锚点读取与还原,供前插补偿使用。
新增 conversationToggleReveal.ts:折叠头解析、头部冻结与超长正文对齐,覆盖 details 与 aria-expanded 两种折叠。
新增 useDelayedFlag.ts:延迟显示开关位,避免本地读取瞬间返回时闪一下加载行。

Co-authored-by: Junie <junie@jetbrains.com>
控制器新增 historyLoading / historyError 两个可渲染状态,与并发判据用的 ref 同步镜像。
loadEarlierHistory 失败时写入 historyError 并挂起自动加载,成功路径清掉失败记录。
新增 retryEarlierHistory:先解挂起再走同一条加载入口,供内联错误行的重试按钮调用。
加载失败不再写顶部状态行(状态行留给首屏读取失败等其它用途)。

Co-authored-by: Junie <junie@jetbrains.com>
- DirectProjectTurn 的正文块、执行过程折叠块与终态文案块写上 data-turn-key
- ToolCallGroup 与 AgentReasoning 新增可选 turnKey 透传,工具组与思考块也带回合身份
- 锚点按「回合 key + 块内序号 + 偏移」定位,不新增 DOM 包裹层

Co-authored-by: Junie <junie@jetbrains.com>
- 删掉常驻的「显示更早的对话」按钮,改由触顶与填充视口两条触发自动加载,失败即挂起并留内联重试行
- 新增 useConversationScroll:列表唯一滚动所有权(跟随最新、ResizeObserver 补偿、前插锚点冻结、胶囊显隐)
- 新增顶部加载行与底部居中 sticky 胶囊两个表现组件,文案与阈值取自 conversationScrollPolicy
- 列表追加 overflow-anchor:none 关掉原生锚定;「有新回复」按最后一轮终态内容指纹判定
- DirectProjectChatView 交出 messagesRef / shouldFollowLatestRef / handleScroll,只传加载状态与回调

Co-authored-by: Junie <junie@jetbrains.com>
- 新增与实现同目录的 5 组用例:阈值与加载门、锚点读取/还原、折叠头冻结与正文对齐、hook 状态机、组件层按钮移除与重试行
- 根 vitest.config.ts 补一条 include,同目录用例才进得了门禁
- 既有断言同步:消息列表类名加上 overflow-anchor 工具类,DirectProjectConversation 用例改用新的加载状态入参

Co-authored-by: Junie <junie@jetbrains.com>
- ADR 修正样式删除口径:.message-history-more 因策划对话仍在用而保留,后续项一并说明
- 历史技术方案去掉「必须显示显示更早的对话按钮」的过期要求,改为引用新 ADR 的入口形态
- 决策记录新增本次滚动契约、边界与验证口径

Co-authored-by: Junie <junie@jetbrains.com>
- 补充程序化平滑滚动独占滚动位置的规则:动画期间滚动事件与布局补偿都不得写 scrollTop
- 补充折叠展开「刚好露出新展开正文」的最小位移规则,并明确收起不额外滚动
- 决策记录追加两个真机缺陷的根因、修正与 Chromium 复验结论

Co-authored-by: Junie <junie@jetbrains.com>
- 新增纯函数 toggleRevealDelta:折叠头冻结与露出正文合成一次 scrollTop 位移
- 正文底边超出视口只补超出量,正文比视口还高才对齐正文顶边,收起不额外滚动
- 补齐用例:纯函数六例与 freezeToggleHead 两条真机行为

Co-authored-by: Junie <junie@jetbrains.com>
修复:点回到底部后平滑滚动不再被自身补偿打断
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m53s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m41s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / Repository checks (pull_request) Failing after 27s
Project CI / Frontend tests (pull_request) Successful in 2m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m24s
Project CI / Native shell tests (pull_request) Successful in 5m42s
238c3c60d7
- 新增 programmaticScrollRef:动画在飞时滚动事件不翻转跟随、布局补偿整段跳过
- 只有真的还要走一段距离才接管滚动位置;滚到贴底或滚轮 / 触摸 / 键盘接手即交还
- 补齐 hook 用例:动画途中胶囊不闪回、不写 scrollTop、落位后与被打断后照常判定

Co-authored-by: Junie <junie@jetbrains.com>
Author
Member
  • 1. 更早历史的加载态可能被「上一个项目」的读取提前关掉(原标 medium)

    • 现状:loadEarlierHistory() 先开加载窗口(useDirectProjectChatController.ts:694-695 的 historyLoadingRef.current = true; setHistoryLoading(true)),
      成功路径(:708 if (projectPathRef.current !== nextProjectPath) return;)与失败路径(catch 第一行)都用项目守卫挡了后到的旧项目结果,
      但收口的 finally(修改前 :713-716)没有守卫,无条件 historyLoadingRef.current = false; setHistoryLoading(false)。
    • 问题:切项目时旧项目那次读取还在飞。新项目自己发起读取之后,旧读取落地 → 旧读取的 finally 把新项目的加载态关掉:
      ① 加载行提前消失,用户看不出还在读;② historyLoadingRef 这道并发闸门被重新打开,同一个项目上能再并起第二次读取
      (同一段历史重复读、historyOldestItemIdRef 被两次推进、前插锚点也会抖)。原 review 判断成立。
    • 修复:finally 补上同一条项目守卫,只有仍属于当前项目的那次读取才关自己的加载态(现在 :724-731)。
    • 回归测试:新增 controller/useDirectProjectChatController.test.tsx,用 window.__TAURI__.core.invoke 把历史切片读取全部挂起:
      先让 /a 的翻页读取挂住 → 切到 /b 并让 /b 也发起翻页读取 → 再让 /a 那次落地,断言 /b 的 historyLoading 仍为 true,
      直到 /b 自己的读取落地才变 false。修前红(expected false to be true)、修后绿。
    • 提交:77401adfe 修复更早历史加载被旧项目读取提前关闭加载态
      (新增测试里 invoke 替身装不进泛型签名的类型修正另起一条小提交 986136171;tsc 通过后这两条提交的代码才是全绿的)
  • 2. 平滑滚动的中间帧翻转「跟随最新」/ 刷新锚点(原标 medium)——已过时,当前实现已经修掉

    • 现状:useConversationScroll.ts 有 programmaticScrollRef(:89-97):
      • 点胶囊时只有真的要再走一段距离才置位(:289-299),避免已经贴底时挂上标记没人交还;
      • onScroll 在动画在飞时直接 return,只有滚到贴底阈值才交还控制权(:257-268);
      • compensateLayout 在飞期间整段跳过、不写 scrollTop(:138-148),也就不会再「写一次 scrollTop 把动画取消在半路」;
      • 滚轮 / 触摸 / 键盘接手立刻交还(:209-225),新一回合开始也会清掉(:227-237)。
    • 结论:review 描述的「中间 scroll 事件翻转 followLatestRef 并刷新 preserveAnchorRef → 补偿写 scrollTop 取消动画 → 用户卡在半路」
      在当前代码里已经不成立,正是上一个提交 238c3c60d 修掉的那个缺陷。本次不再改代码,仅留档说明这条已过期。
    • 提交:238c3c60d 修复:点回到底部后平滑滚动不再被自身补偿打断(本次无新提交)
  • 3. 切换项目时滚动所有权状态不重置(原标 medium)

    • 现状(修改前):useConversationScroll 的滚动所有权状态 followLatestRef / atBottom / hasNewReply / preserveAnchorRef
      (:86-103)只在挂载时初始化一次,之后没有任何重置入口;DirectProjectChatView 只渲染一处、没有 key,
      内部 <DirectProjectConversation> 也没有 key,所以切项目时这个组件不会重挂载,hook 里的旧值一直留着。
      对照之下控制器自己按项目重置了历史状态(useDirectProjectChatController.ts:212-223)。原 review 判断成立。
    • 问题:在项目 A 往上滚过之后切到项目 B:followLatestRef 仍是 false、atBottom 仍是 false,于是
      ① B 的首屏不会自动贴底,用户得自己往下滚;② turns 一换,终态指纹变化而在不跟随分支里触发 setHasNewReply(true),
      胶囊在新项目上直接显示「有新回复 · 回到底部」,而用户根本没在这个项目里离开过底部。
    • 修复(采用推荐 A:显式重置信号):useConversationScroll 增加必填的 conversationKey,身份变化时复位
      programmaticScrollRef / followLatestRef / preserveAnchorRef / foldRef,把 terminalSignatureRef 同步成新会话内容,
      清掉 atBottom / hasNewReply 并贴底;复位 effect 排在「内容变化」effect 之前,同一次提交里的内容变化因此不会被算成「有新回复」。
      身份键由 DirectProjectConversation 透传、DirectProjectChatView 传 projectPath ?? '';没有改成 key(B),
      避免整份消息列表连同加载行的 150ms 延迟计时被一起重建。
    • 回归测试:useConversationScroll.test.tsx 新增三例(新会话首屏贴底且胶囊为空、切会话后在 B 里往上滚只显示「回到底部」、
      复位之后新会话里不跟随时内容增加仍点亮「有新回复」);DirectProjectConversation.test.tsx 新增一例断言换身份时
      .project-chat-message-list 仍是同一个 DOM 节点,守住「不靠 key 重建」这个取舍。
    • 提交:ccc9b776d 修复:切项目时复位 DirectProject 对话滚动所有权(文档先行的 ADR 第 6 条与决策日志在 95a32a316)
    • 仍需真机验收:在 A 里往上滚 → 切 B → B 首屏应贴底且不出现胶囊;同一个项目重开不应丢掉「正在加载更早历史」。
  • 4. historyError 为空串时挂起闸门与错误行一起消失(原标 low)

    • 现状:失败时 const message = error instanceof Error ? error.message : String(error)(修改前 :710)直接写进
      historyErrorRef / setHistoryError。下游只把它当真假用:canStartHistoryLoad 的 !gate.historyError
      (conversationScrollPolicy.ts:86-88)、loadEarlierHistory 开头的 historyErrorRef.current 守卫、
      以及 DirectProjectConversation.tsx:78 的 historyError ? <DirectProjectHistoryErrorRow/>;错误文案本身从来不渲染,
      内联行只显示固定文案「加载更早对话失败」。
    • 问题:宿主抛 new Error()(或空串)时 message 是 '',是 falsy:挂起闸门和错误行一起消失,
      用户滚到顶会反复重试同一个失败读取,而且没有任何反馈。readDirectHistoryPages 会把抛出值原样带回来(directHistoryPaging.ts:58-64),
      所以空 message 确实能走到这里。原 review 判断成立。
    • 修复:失败消息统一收口成非空兜底文案 DIRECT_HISTORY_LOAD_ERROR_FALLBACK = '读取更早的对话历史失败'
      (useDirectProjectChatController.ts:47-54),空 message 不会再让 historyError 变 falsy。
      保留「非空即挂起」的既有契约(ADR 也是这么写的),比把三处判据都改成 !== null 更小、更稳。
    • 回归测试:同文件新增用例,让翻页读取 reject(new Error('')),断言 historyError 仍为真值、historyLoading 已收口。
      修前红(expected '' to be truthy)、修后绿。
    • 提交:9cff7bf04 修复更早历史读取失败消息为空时挂起态与错误行一起消失
- [x] 1. 更早历史的加载态可能被「上一个项目」的读取提前关掉(原标 medium) - 现状:`loadEarlierHistory()` 先开加载窗口(useDirectProjectChatController.ts:694-695 的 `historyLoadingRef.current = true; setHistoryLoading(true)`), 成功路径(:708 `if (projectPathRef.current !== nextProjectPath) return;`)与失败路径(catch 第一行)都用项目守卫挡了后到的旧项目结果, 但收口的 `finally`(修改前 :713-716)没有守卫,无条件 `historyLoadingRef.current = false; setHistoryLoading(false)`。 - 问题:切项目时旧项目那次读取还在飞。新项目自己发起读取之后,旧读取落地 → 旧读取的 `finally` 把**新项目**的加载态关掉: ① 加载行提前消失,用户看不出还在读;② `historyLoadingRef` 这道并发闸门被重新打开,同一个项目上能再并起第二次读取 (同一段历史重复读、`historyOldestItemIdRef` 被两次推进、前插锚点也会抖)。原 review 判断成立。 - 修复:`finally` 补上同一条项目守卫,只有仍属于当前项目的那次读取才关自己的加载态(现在 :724-731)。 - 回归测试:新增 `controller/useDirectProjectChatController.test.tsx`,用 `window.__TAURI__.core.invoke` 把历史切片读取全部挂起: 先让 `/a` 的翻页读取挂住 → 切到 `/b` 并让 `/b` 也发起翻页读取 → 再让 `/a` 那次落地,断言 `/b` 的 `historyLoading` 仍为 `true`, 直到 `/b` 自己的读取落地才变 `false`。修前红(`expected false to be true`)、修后绿。 - 提交:`77401adfe` 修复更早历史加载被旧项目读取提前关闭加载态 (新增测试里 invoke 替身装不进泛型签名的类型修正另起一条小提交 `986136171`;`tsc` 通过后这两条提交的代码才是全绿的) - [x] 2. 平滑滚动的中间帧翻转「跟随最新」/ 刷新锚点(原标 medium)——**已过时,当前实现已经修掉** - 现状:`useConversationScroll.ts` 有 `programmaticScrollRef`(:89-97): - 点胶囊时只有真的要再走一段距离才置位(:289-299),避免已经贴底时挂上标记没人交还; - `onScroll` 在动画在飞时直接 `return`,只有滚到贴底阈值才交还控制权(:257-268); - `compensateLayout` 在飞期间整段跳过、不写 `scrollTop`(:138-148),也就不会再「写一次 `scrollTop` 把动画取消在半路」; - 滚轮 / 触摸 / 键盘接手立刻交还(:209-225),新一回合开始也会清掉(:227-237)。 - 结论:review 描述的「中间 scroll 事件翻转 `followLatestRef` 并刷新 `preserveAnchorRef` → 补偿写 `scrollTop` 取消动画 → 用户卡在半路」 在当前代码里已经不成立,正是上一个提交 `238c3c60d` 修掉的那个缺陷。本次不再改代码,仅留档说明这条已过期。 - 提交:`238c3c60d` 修复:点回到底部后平滑滚动不再被自身补偿打断(本次无新提交) - [x] 3. 切换项目时滚动所有权状态不重置(原标 medium) - 现状(修改前):`useConversationScroll` 的滚动所有权状态 `followLatestRef` / `atBottom` / `hasNewReply` / `preserveAnchorRef` (:86-103)只在挂载时初始化一次,之后没有任何重置入口;`DirectProjectChatView` 只渲染一处、没有 `key`, 内部 `<DirectProjectConversation>` 也没有 `key`,所以切项目时这个组件不会重挂载,hook 里的旧值一直留着。 对照之下控制器自己按项目重置了历史状态(useDirectProjectChatController.ts:212-223)。原 review 判断成立。 - 问题:在项目 A 往上滚过之后切到项目 B:`followLatestRef` 仍是 `false`、`atBottom` 仍是 `false`,于是 ① B 的首屏不会自动贴底,用户得自己往下滚;② `turns` 一换,终态指纹变化而在不跟随分支里触发 `setHasNewReply(true)`, 胶囊在新项目上直接显示「有新回复 · 回到底部」,而用户根本没在这个项目里离开过底部。 - 修复(采用推荐 A:显式重置信号):`useConversationScroll` 增加必填的 `conversationKey`,身份变化时复位 `programmaticScrollRef` / `followLatestRef` / `preserveAnchorRef` / `foldRef`,把 `terminalSignatureRef` 同步成新会话内容, 清掉 `atBottom` / `hasNewReply` 并贴底;复位 effect 排在「内容变化」effect 之前,同一次提交里的内容变化因此不会被算成「有新回复」。 身份键由 `DirectProjectConversation` 透传、`DirectProjectChatView` 传 `projectPath ?? ''`;没有改成 `key`(B), 避免整份消息列表连同加载行的 150ms 延迟计时被一起重建。 - 回归测试:`useConversationScroll.test.tsx` 新增三例(新会话首屏贴底且胶囊为空、切会话后在 B 里往上滚只显示「回到底部」、 复位之后新会话里不跟随时内容增加仍点亮「有新回复」);`DirectProjectConversation.test.tsx` 新增一例断言换身份时 `.project-chat-message-list` 仍是同一个 DOM 节点,守住「不靠 key 重建」这个取舍。 - 提交:`ccc9b776d` 修复:切项目时复位 DirectProject 对话滚动所有权(文档先行的 ADR 第 6 条与决策日志在 `95a32a316`) - 仍需真机验收:在 A 里往上滚 → 切 B → B 首屏应贴底且不出现胶囊;同一个项目重开不应丢掉「正在加载更早历史」。 - [x] 4. `historyError` 为空串时挂起闸门与错误行一起消失(原标 low) - 现状:失败时 `const message = error instanceof Error ? error.message : String(error)`(修改前 :710)直接写进 `historyErrorRef` / `setHistoryError`。下游只把它当真假用:`canStartHistoryLoad` 的 `!gate.historyError` (conversationScrollPolicy.ts:86-88)、`loadEarlierHistory` 开头的 `historyErrorRef.current` 守卫、 以及 `DirectProjectConversation.tsx:78` 的 `historyError ? <DirectProjectHistoryErrorRow/>`;错误文案本身从来不渲染, 内联行只显示固定文案「加载更早对话失败」。 - 问题:宿主抛 `new Error()`(或空串)时 message 是 `''`,是 falsy:挂起闸门和错误行一起消失, 用户滚到顶会反复重试同一个失败读取,而且没有任何反馈。`readDirectHistoryPages` 会把抛出值原样带回来(directHistoryPaging.ts:58-64), 所以空 message 确实能走到这里。原 review 判断成立。 - 修复:失败消息统一收口成非空兜底文案 `DIRECT_HISTORY_LOAD_ERROR_FALLBACK = '读取更早的对话历史失败'` (useDirectProjectChatController.ts:47-54),空 message 不会再让 `historyError` 变 falsy。 保留「非空即挂起」的既有契约(ADR 也是这么写的),比把三处判据都改成 `!== null` 更小、更稳。 - 回归测试:同文件新增用例,让翻页读取 `reject(new Error(''))`,断言 `historyError` 仍为真值、`historyLoading` 已收口。 修前红(`expected '' to be truthy`)、修后绿。 - 提交:`9cff7bf04` 修复更早历史读取失败消息为空时挂起态与错误行一起消失
k88936 added 6 commits 2026-10-02 19:21:22 +08:00
- loadEarlierHistory 的 finally 补上项目归属守卫,旧项目在飞的读取落地时不再清掉新项目的加载态与并发闸门
- 新增控制器回归用例:切项目后旧读取落地,新项目的加载态保持到它自己收口

Co-authored-by: Junie <junie@jetbrains.com>
- 失败消息统一收口成非空兜底文案,空 message 不再让 historyError 变 falsy
- 新增控制器回归用例:宿主抛 new Error() 时挂起态仍成立

Co-authored-by: Junie <junie@jetbrains.com>
- vi.fn 返回固定签名,装不进 window.__TAURI__.core.invoke 的泛型签名,显式转成 TauriInvoke
- tsc 不再报 TS2322;用例行为与断言不变

Co-authored-by: Junie <junie@jetbrains.com>
- ADR 新增第 6 条:会话身份作为显式信号复位跟随最新/锚点/胶囊状态,并记录「key 重建列表」的取舍
- 决策记录追加换会话复位的症状、决策与验证口径

Co-authored-by: Junie <junie@jetbrains.com>
useConversationScroll 新增 conversationKey 身份信号,身份变化时复位跟随最新 / 贴底 / 有新回复 / 前插锚点 / 折叠头 / 程序化滚动标记

复位时把终态指纹同步成新会话内容并重新贴底,避免同一次提交里的内容变化被算成「有新回复」

DirectProjectConversation 透传身份键,DirectProjectChatView 传项目路径,不重建列表 DOM

新增 3 条换会话用例与 1 条组件用例,组件用例抽成 conversationElement 以便 rerender

Co-authored-by: Junie <junie@jetbrains.com>
Merge remote-tracking branch 'origin/master' into feat/smoother-dialog-history
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m30s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m51s
Project CI / Backend tests (pull_request) Successful in 4m53s
Project CI / Frontend tests (pull_request) Successful in 2m33s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m27s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / Repository checks (pull_request) Successful in 3m4s
97e8f1b652
Author
Member

本轮 3 条我都在当前代码上核过:1、2 上一轮已修;3 按你选的方案 B(换一套块身份)本轮已修,只留真机观感验收。
(本文件是临时工作文件:不提交、不覆盖你自己的编辑。)

  • 1. 更早历史读取的「项目路径守卫」分不清同一项目的两次加载(原标 bug · medium)

    • 位置(修改前):apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts:728-731(finally 那段),
      同一条守卫在成功路径与 catch 里各有一份。
    • 现状:loadEarlierHistory 的三处守卫都只比 projectPathRef.current === nextProjectPath;而 historyLoadingRef 身兼两职——
      既是渲染顶部加载行的状态,又是「同一时间只允许一次读取」的并发闸门。
    • 问题:A→B→A 之后再回到 A,第一次 A 的读取才落地,它的路径和当前又一致,守卫于是放行:
      ① finally 把 historyLoadingRef / historyLoading 关掉——新一代 A 的加载行提前消失,闸门被重新打开,同一个项目上还能再并起第三次读取;
      ② 同一段 try 里它照样合并旧条目、覆盖 historyOldestItemIdRef 游标。只比路径区分不出「同一项目的上一世代」。
    • 修复(采纳 review 建议的单调 token):新增 historyLoadTokenRef——切项目时 +1、每次读取启动时 +1;
      读取落地时拿启动时捕获的 loadToken 与当前值比对(isStaleLoad),过期就整段丢弃:不合并条目、不写游标、不关加载态,失败也不算到当代头上。
    • 回归测试:controller/useDirectProjectChatController.test.tsx 新增一例,驱动 A→B→A 且同路径新读取在飞,
      断言旧读取落地后 historyLoading 仍为真、由新一代自己收口;修正前确实红。
    • 提交:e3c3091ec 修复:更早历史读取按世代号失效旧请求
    • 同类但未改:loadFirstScreenHistory 仍是按路径守卫;它在切项目时由 effect 的 disposed 兜住,且首屏读取可能被同一项目连调两次,
      要一起改得先定「两次调用谁作废」的口径,本次没动。
  • 2. 锚点读取与还原每个滚动帧都全表查询(原标 performance · medium)

    • 位置:components/DirectProjectConversation/conversationScrollAnchor.ts:56-58(collectTurnBlocks,现在在 :47-55)。
    • 现状:readTopVisibleTurnAnchor(onScroll 里每次滚动都调)与 restoreTurnAnchor → findTurnBlock 各自调一次 collectTurnBlocks;
      后者是 list.querySelectorAll('[data-turn-key]'),之后还要逐块 getBoundingClientRect() 找第一条露出视口的块。
    • 问题:一次滚动帧读一次锚点、一次 compensateLayout 还原一次锚点,就是每个滚动帧两三遍整份列表的查询;
      列表可以无限往上加载,块数越大滚动越抖(review 判断准确)。
    • 修复(采纳 review 建议的缓存 + 现有失效信号):collectTurnBlocks 的结果按列表缓存进 WeakMap,新增 invalidateTurnBlocks;
      useConversationScroll 里已有的子元素 MutationObserver(原本只用来重订阅 ResizeObserver)在 subscribe 里顺手失效缓存。
      失效信号选子元素增删是有依据的:只有子元素变化才可能改变「哪些块带回合身份」,而 running→finished 的 details 包裹同样是列表直接子元素增删。
      没采纳 review 的另一个备选(从上次锚点续扫):那改的是遍历策略,还要引入跨帧状态,和缓存不是同一档改动。
    • 限界:只省掉查询,没省掉「逐块读 getBoundingClientRect()」这次遍历;要再压得改成从上次锚点续扫,属于另一次取舍。
    • 回归测试:conversationScrollAnchor.test.ts 新增两例——同一列表连续收集只查一次 DOM(spy querySelectorAll)、子元素变化并 invalidateTurnBlocks 后重新收集。
    • 提交:1caf8ae8b 优化:会话滚动锚点复用块集合缓存
    • 文档:ADR 第 2 条与决策记录已同步(a589dad00)。
    • 后续补充:本轮第 3 条把选择器改成 [data-turn-key][data-block-key],缓存与失效机制未变。
  • 3. findTurnBlock 用位置序号定位,回合 running→finished 后序号错位(原标 bug · low)——本轮已按「换一套块身份」修好

    • 位置(修改前):conversationScrollAnchor.ts:43(当时的 findTurnBlock)与 readTopVisibleTurnAnchor / restoreTurnAnchor;
      结构来源是 components/DirectProjectConversation/DirectProjectTurn.tsx:53-70 的 renderTurnProcess。
    • 现状(修改前):锚点 = {key, index, offset},findTurnBlock 取同一 data-turn-key 下的第 index 个块(blocks[index] ?? null),
      只在「这个序号取不到任何块」时回落到 null;文件头写的不变式是「已有回合的 key、块序号和块内结构都不变」,这只在前插时成立。
      而 renderTurnProcess 对运行中的回合把 process 块平铺,对已结束的回合把它们包进一个新的 <details data-turn-key={turn.key}>。
    • 问题(已按当前代码核实):以「用户气泡 + 两个工具组」的回合为例——运行中文档顺序是 [0]=用户气泡, [1]=工具组 1, [2]=工具组 2;
      收口后变成 [0]=用户气泡, [1]=<details>, [2]=工具组 1, [3]=工具组 2, [4]=终态正文, [5]=用量。
      原来指向 index=2(第二个工具组)的锚点现在解析到第一个工具组,restoreTurnAnchor 按错误基准写 scrollTop,表现为一帧跳动。
      同一场景里还有一个更隐蔽的点:被折进 <details> 的 process 块是收起的(没有 open),真实浏览器里它的 getBoundingClientRect() 全是 0,
      所以即使序号对上也算不出可用偏移;「锚点块已被折叠隐藏」本身需要一条规则。
    • 修复(你选的 B「换一套块身份」,稳定且简单):
      ① 锚点改存 blockKey,即 DirectChatBlock.key(条目块是 ${回合 key}:${条目 itemId},本地说明块是 messageId),只要求同一回合内唯一;
      ② 展示层给每个可锚定块加 data-block-key:DirectProjectTurn 的正文块用 block.key,「执行过程」包装块与终态文案各用一个回合内唯一的字面量块身份,
      ToolCallGroup / AgentReasoning 新增可选 blockKey 透传;
      ③ findTurnBlock 按「回合 key + 块身份」定位,收集选择器收敛为 [data-turn-key][data-block-key](缺块身份的块干脆不进候选);
      ④ 补上 review 点出的另一半:锚点块没有布局盒(被折进收起的 <details>)时读锚点跳过它、restoreTurnAnchor 判失败返回 false,
      调用方 useConversationScroll.compensateLayout 拿当前位置重新起锚——不回跳,也不按别的块硬对齐(未采纳「改找同回合其它可见块」:那会按另一条消息的基准写 scrollTop)。
    • 回归测试(TDD,修正前确实红):conversationScrollAnchor.test.ts 改为按 [回合 key, 块身份] 构造,新增
      「块序号变了不影响定位(收口把过程折进 details)」「回合收口后按块身份仍还原到同一个块」「锚点块被折进收起的 details:读锚点跳过、还原返回 false 且不动 scrollTop」
      「只收集同时带回合 key 与块身份的块」;DirectProjectConversation.test.tsx 新增「每个可锚定块都有 data-block-key 且同回合内唯一」
      「running→finished 前后块序号会变、块身份不变」。
    • 提交:9f7c2e318(ADR + 决策记录,文档先行)、42de1b4c3(展示层加块身份 + 组件用例)、429557ea6(锚点定位与判失败规则 + 纯函数用例)。
    • 验证:chat 范围 64 例、directProjectTurn / directProjectProcessStatus 全绿;appSurface 195 passed / 9 skipped(既有跳过);
      ai-game-creator-shell:typecheck、eslint --max-warnings 0、check:encoding(5124 文件)、git diff --check 通过。
    • 仍待真机验收(jsdom 没有布局,无法在此证明):在长回合运行中往上滚到它的过程块 → 等回合收口 → 视口不应出现跳动
      (锚点块被折进收起的 details 时允许不自动对齐,但不应该跳)。
本轮 3 条我都在当前代码上核过:1、2 上一轮已修;3 按你选的方案 B(换一套块身份)本轮已修,只留真机观感验收。 (本文件是临时工作文件:不提交、不覆盖你自己的编辑。) - [x] 1. 更早历史读取的「项目路径守卫」分不清同一项目的两次加载(原标 bug · medium) - 位置(修改前):`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts:728-731`(`finally` 那段), 同一条守卫在成功路径与 `catch` 里各有一份。 - 现状:`loadEarlierHistory` 的三处守卫都只比 `projectPathRef.current === nextProjectPath`;而 `historyLoadingRef` 身兼两职—— 既是渲染顶部加载行的状态,又是「同一时间只允许一次读取」的并发闸门。 - 问题:A→B→A 之后再回到 A,第一次 A 的读取才落地,它的路径和当前又一致,守卫于是放行: ① `finally` 把 `historyLoadingRef` / `historyLoading` 关掉——新一代 A 的加载行提前消失,闸门被重新打开,同一个项目上还能再并起第三次读取; ② 同一段 `try` 里它照样合并旧条目、覆盖 `historyOldestItemIdRef` 游标。只比路径区分不出「同一项目的上一世代」。 - 修复(采纳 review 建议的单调 token):新增 `historyLoadTokenRef`——切项目时 +1、每次读取启动时 +1; 读取落地时拿启动时捕获的 `loadToken` 与当前值比对(`isStaleLoad`),过期就整段丢弃:不合并条目、不写游标、不关加载态,失败也不算到当代头上。 - 回归测试:`controller/useDirectProjectChatController.test.tsx` 新增一例,驱动 A→B→A 且同路径新读取在飞, 断言旧读取落地后 `historyLoading` 仍为真、由新一代自己收口;修正前确实红。 - 提交:`e3c3091ec` 修复:更早历史读取按世代号失效旧请求 - 同类但未改:`loadFirstScreenHistory` 仍是按路径守卫;它在切项目时由 effect 的 `disposed` 兜住,且首屏读取可能被同一项目连调两次, 要一起改得先定「两次调用谁作废」的口径,本次没动。 - [x] 2. 锚点读取与还原每个滚动帧都全表查询(原标 performance · medium) - 位置:`components/DirectProjectConversation/conversationScrollAnchor.ts:56-58`(`collectTurnBlocks`,现在在 :47-55)。 - 现状:`readTopVisibleTurnAnchor`(`onScroll` 里每次滚动都调)与 `restoreTurnAnchor` → `findTurnBlock` 各自调一次 `collectTurnBlocks`; 后者是 `list.querySelectorAll('[data-turn-key]')`,之后还要逐块 `getBoundingClientRect()` 找第一条露出视口的块。 - 问题:一次滚动帧读一次锚点、一次 `compensateLayout` 还原一次锚点,就是每个滚动帧两三遍整份列表的查询; 列表可以无限往上加载,块数越大滚动越抖(review 判断准确)。 - 修复(采纳 review 建议的缓存 + 现有失效信号):`collectTurnBlocks` 的结果按列表缓存进 `WeakMap`,新增 `invalidateTurnBlocks`; `useConversationScroll` 里已有的子元素 `MutationObserver`(原本只用来重订阅 `ResizeObserver`)在 `subscribe` 里顺手失效缓存。 失效信号选子元素增删是有依据的:只有子元素变化才可能改变「哪些块带回合身份」,而 running→finished 的 details 包裹同样是列表直接子元素增删。 没采纳 review 的另一个备选(从上次锚点续扫):那改的是遍历策略,还要引入跨帧状态,和缓存不是同一档改动。 - 限界:只省掉查询,没省掉「逐块读 `getBoundingClientRect()`」这次遍历;要再压得改成从上次锚点续扫,属于另一次取舍。 - 回归测试:`conversationScrollAnchor.test.ts` 新增两例——同一列表连续收集只查一次 DOM(spy `querySelectorAll`)、子元素变化并 `invalidateTurnBlocks` 后重新收集。 - 提交:`1caf8ae8b` 优化:会话滚动锚点复用块集合缓存 - 文档:ADR 第 2 条与决策记录已同步(`a589dad00`)。 - 后续补充:本轮第 3 条把选择器改成 `[data-turn-key][data-block-key]`,缓存与失效机制未变。 - [x] 3. `findTurnBlock` 用位置序号定位,回合 running→finished 后序号错位(原标 bug · low)——**本轮已按「换一套块身份」修好** - 位置(修改前):`conversationScrollAnchor.ts:43`(当时的 `findTurnBlock`)与 `readTopVisibleTurnAnchor` / `restoreTurnAnchor`; 结构来源是 `components/DirectProjectConversation/DirectProjectTurn.tsx:53-70` 的 `renderTurnProcess`。 - 现状(修改前):锚点 = `{key, index, offset}`,`findTurnBlock` 取同一 `data-turn-key` 下的第 `index` 个块(`blocks[index] ?? null`), 只在「这个序号取不到任何块」时回落到 `null`;文件头写的不变式是「已有回合的 key、块序号和块内结构都不变」,这只在前插时成立。 而 `renderTurnProcess` 对运行中的回合把 process 块**平铺**,对已结束的回合把它们包进一个新的 `<details data-turn-key={turn.key}>`。 - 问题(已按当前代码核实):以「用户气泡 + 两个工具组」的回合为例——运行中文档顺序是 `[0]=用户气泡, [1]=工具组 1, [2]=工具组 2`; 收口后变成 `[0]=用户气泡, [1]=<details>, [2]=工具组 1, [3]=工具组 2, [4]=终态正文, [5]=用量`。 原来指向 `index=2`(第二个工具组)的锚点现在解析到**第一个工具组**,`restoreTurnAnchor` 按错误基准写 `scrollTop`,表现为一帧跳动。 同一场景里还有一个更隐蔽的点:被折进 `<details>` 的 process 块是**收起**的(没有 `open`),真实浏览器里它的 `getBoundingClientRect()` 全是 0, 所以即使序号对上也算不出可用偏移;「锚点块已被折叠隐藏」本身需要一条规则。 - 修复(你选的 B「换一套块身份」,稳定且简单): ① 锚点改存 `blockKey`,即 `DirectChatBlock.key`(条目块是 `${回合 key}:${条目 itemId}`,本地说明块是 `messageId`),只要求同一回合内唯一; ② 展示层给每个可锚定块加 `data-block-key`:`DirectProjectTurn` 的正文块用 `block.key`,「执行过程」包装块与终态文案各用一个回合内唯一的字面量块身份, `ToolCallGroup` / `AgentReasoning` 新增可选 `blockKey` 透传; ③ `findTurnBlock` 按「回合 key + 块身份」定位,收集选择器收敛为 `[data-turn-key][data-block-key]`(缺块身份的块干脆不进候选); ④ 补上 review 点出的另一半:锚点块没有布局盒(被折进收起的 `<details>`)时读锚点跳过它、`restoreTurnAnchor` 判失败返回 false, 调用方 `useConversationScroll.compensateLayout` 拿当前位置重新起锚——不回跳,也不按别的块硬对齐(未采纳「改找同回合其它可见块」:那会按另一条消息的基准写 `scrollTop`)。 - 回归测试(TDD,修正前确实红):`conversationScrollAnchor.test.ts` 改为按 `[回合 key, 块身份]` 构造,新增 「块序号变了不影响定位(收口把过程折进 details)」「回合收口后按块身份仍还原到同一个块」「锚点块被折进收起的 details:读锚点跳过、还原返回 false 且不动 scrollTop」 「只收集同时带回合 key 与块身份的块」;`DirectProjectConversation.test.tsx` 新增「每个可锚定块都有 data-block-key 且同回合内唯一」 「running→finished 前后块序号会变、块身份不变」。 - 提交:`9f7c2e318`(ADR + 决策记录,文档先行)、`42de1b4c3`(展示层加块身份 + 组件用例)、`429557ea6`(锚点定位与判失败规则 + 纯函数用例)。 - 验证:chat 范围 64 例、`directProjectTurn` / `directProjectProcessStatus` 全绿;`appSurface` 195 passed / 9 skipped(既有跳过); `ai-game-creator-shell:typecheck`、`eslint --max-warnings 0`、`check:encoding`(5124 文件)、`git diff --check` 通过。 - 仍待真机验收(jsdom 没有布局,无法在此证明):在长回合运行中往上滚到它的过程块 → 等回合收口 → 视口不应出现跳动 (锚点块被折进收起的 details 时允许不自动对齐,但不应该跳)。
k88936 added 7 commits 2026-10-02 20:20:12 +08:00
loadEarlierHistory 的成功 / 失败 / finally 守卫从「项目路径相等」改成「世代号相等」,A→B→A 之后在飞的旧读取不会再清掉新一代的加载态

新增 historyLoadTokenRef,切项目与每次读取各推进一格,旧世代落地时整段丢弃:不合并条目、不写游标、不关并发闸门

新增一例控制器用例驱动 A→B→A 与同路径新读取在飞,修正前在「旧读取落地」处确实红

Co-authored-by: Junie <junie@jetbrains.com>
collectTurnBlocks 结果按列表缓存(WeakMap),读锚点与还原锚点在同一帧内不再各查一遍整份列表

useConversationScroll 的 MutationObserver 在子元素增删时调 invalidateTurnBlocks,失效信号与重订阅共用同一处

新增两例纯函数用例:连续收集只查一次 DOM、子元素变化并失效后重新收集

Co-authored-by: Junie <junie@jetbrains.com>
- ADR 第 1 条补「一次加载与它所属的世代绑定」,第 2 条补块集合缓存与失效信号
- 决策记录追加两处内部缺陷的症状、修正与验证口径

Co-authored-by: Junie <junie@jetbrains.com>
锚点契约从「回合 key + 块序号 + 偏移」改为「回合 key + 块身份(data-block-key)+ 偏移」,写明 running→finished 收口后块序号整体后移、序号锚点会解析到隔壁块的原因。

补记锚点块没有布局盒(被折进收起的 details)时读锚点跳过、还原判失败并放弃这次补偿的规则。

备选方案补记被否掉的「锚点存块序号」与「改找同回合其它可见块」,验收清单补「长回合收口时视口不跳」。

决策记录同步同一条决策与测试口径。

Co-authored-by: Junie <junie@jetbrains.com>
DirectProjectTurn 给正文块写 data-block-key={block.key},「执行过程」包装块与终态文案各给一个回合内唯一的字面量块身份。

ToolCallGroup 与 AgentReasoning 新增可选 blockKey 并透传到 data-block-key,turnKey 的注释改成「按回合 key + 块身份定位」。

组件用例断言每个可锚定块都有块身份且同回合内唯一,并用 running→finished 前后对照钉住「块序号会整体后移、块身份不变」。

Co-authored-by: Junie <junie@jetbrains.com>
锚点从「回合 key + 块序号」改为「回合 key + 块身份」,findTurnBlock 按 data-block-key 定位:回合收口时过程块被折进 details,块序号会整体后移,原实现会解析到隔壁块并按错误基准写 scrollTop。

收集选择器收敛为 [data-turn-key][data-block-key],只把可定位的块计入候选。

锚点块没有布局盒(被折进收起的 details)时读锚点跳过它、restoreTurnAnchor 判失败并放弃这次补偿,调用方拿当前位置重新起锚。

用例补齐:块序号后移后按块身份仍还原到同一块、收起的 details 里的块不参与读取也不参与还原、只收集带块身份的块。

Co-authored-by: Junie <junie@jetbrains.com>
Merge remote-tracking branch 'origin/master' into feat/smoother-dialog-history
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m41s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m25s
Project CI / Backend tests (pull_request) Successful in 4m46s
Project CI / Frontend tests (pull_request) Successful in 2m30s
Project CI / Repository checks (pull_request) Successful in 2m58s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m14s
Project CI / Native shell tests (pull_request) Successful in 5m37s
ff88ad9210
k88936 added 4 commits 2026-10-02 22:35:26 +08:00
- 内联错误行由 <p role=alert> 改为普通容器,role=alert 只包住错误文案本身
- 重试按钮留在 live region 之外,避免被卷进 assertive + atomic 的整体播报
- 组件用例断言 alert 不包含重试按钮,重试仍是可点击的唯一手动出路
- ADR 同步错误行的无障碍结构

Co-authored-by: Junie <junie@jetbrains.com>
- 程序化平滑滚动期间布局补偿不再直接跳过:内容变高时以新的 scrollHeight 重发平滑滚动
- 避免动画停在旧目标后 programmaticScrollRef 等不到贴底事件、跟随与补偿整段挂起
- hook 用例断言动画目标从 [1200] 变为 [1200,1500] 且仍能交还控制权
- ADR 第 3 条补一条口径,决策记录补齐本条与错误行结构两条

Co-authored-by: Junie <junie@jetbrains.com>
- 换项目时 historyLoadTokenRef 与 projectPathRef 同一处推进,复位 effect 不再推进
- 旧读取在「切换提交完成、复位 effect 未跑」的窗口落地时立刻失效,不再合并条目或写游标
- ADR 第 1 条与决策记录说明推进时机与其安全性依据(该窗口内不会新起带旧游标的读取)

Co-authored-by: Junie <junie@jetbrains.com>
注释:为会话滚动锚点的全表量测补 TODO 性能分析
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 4m18s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m33s
Project CI / Backend tests (pull_request) Successful in 3m58s
Project CI / Repository checks (pull_request) Successful in 3m4s
Project CI / Frontend tests (pull_request) Successful in 3m25s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m14s
Project CI / Native shell tests (pull_request) Successful in 5m49s
1874a6e9ec
- conversationScrollAnchor.ts 的 readTopVisibleTurnAnchor 循环上方新增 // TODO(perf): 注释,仅注释、无行为变化
- 记录量测口径:最坏 O(锚点之前的块数) 次 getBoundingClientRect,同帧首次调用后布局已干净、后续不再各自触发 reflow,属 low,不是振荡源
- 记录 rAF 节流与「只在补偿前才读」都省不掉这次遍历,故不采纳
- 记录二分 / 续扫被收起 <details> 的 0 矩形打断单调性,需先定「隐藏块怎么参与」
- 首选后续方向:缓存上次命中的块身份并从附近续扫,不破坏单调性假设

Co-authored-by: Junie <junie@jetbrains.com>
Author
Member

本文件是临时工作文件(不提交;按你的要求放在 .git/info/exclude 里)。
本轮 review 的 5 条我都在当前代码上核过:1、2、3 修好并各自单独提交;4 你决定不修(一行差异可忽略);5 按你的要求只加 TODO 注释、不改实现。
(你本地已有的 .env 改动我没碰。)

  • 1. 历史加载失败行的重试按钮嵌在 role="alert" 里(原标 other · low)

    • 现状(修改前):apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectHistoryRow.tsx:40-44 的
      DirectProjectHistoryErrorRow 把整行做成 <p ... role="alert">,重试 <button> 是它的子节点。
    • 问题:role="alert" 隐含 aria-live="assertive" + aria-atomic="true",整行会被当成一条断言性播报;
      可交互控件嵌在 live region 内部,读屏不一定会把它当可聚焦按钮播报,用户交互也落在播报区里。review 的判断成立。
    • 修复:容器改成普通 <div>(布局类原样保留),role="alert" 只包住「加载更早对话失败」文案,重试按钮成为 live region 之外的兄弟;
      仍是同一行、同样的样式,重试仍是按钮移除后唯一的手动出路。
    • 用例:DirectProjectConversation.test.tsx 新增一例,断言 role="alert" 节点不包含重试按钮、文案仍在 alert 内、点击仍触发重试。修正前该断言红。
    • 提交:8f91227d1 修复:历史加载错误行的重试按钮移出断言式 live region
    • 文档:ADR 第 1 条的错误行口径同步为「alert 只包文案、按钮在 live region 之外」。
  • 2. 平滑滚动期间内容变高,programmaticScrollRef 卡住不交还(原标 bug · medium)

    • 现状(修改前):components/DirectProjectConversation/useConversationScroll.ts 的 compensateLayout 在 programmaticScrollRef.current 为真时直接 return;
      scrollToBottom 用点击那一刻的 list.scrollHeight 作为平滑滚动目标;标记只在「贴底」的那次滚动事件、或滚轮 / 触摸 / 键盘接手时交还。
    • 问题:流式正文、图片撑开会让 scrollHeight 在动画途中继续增长,动画停在旧目标上,nearBottom 永远不成立 → 标记永远为真 →
      布局补偿整段跳过(列表不再跟随新内容),而 scrollToBottom 已把 atBottom 置真、胶囊按「已贴底」隐掉:
      用户停在底部之上,既没有指示也没有自动跟随,只能靠手动滚一下才解开。review 的判断成立。
    • 修复:程序化滚动期间仍然不写 scrollTop(写一次就会取消动画),但内容变高时把动画目标重新对准新的底部
      (compensateLayout 里改调 scrollListToBottom(list, 'smooth'));动画落到真正的底后照常在贴底事件里交还。
      没有采纳 review 的另一半备选 scrollend:它在部分 WebView / Safari 版本上不可用,而且把目标重对准底部之后动画自己会发贴底事件,不需要另加监听。
    • 用例:useConversationScroll.test.tsx 新增一例——点击回到底部后 scrollHeight 1200→1500 并触发一次布局变化,
      断言动画目标由 [1200] 变为 [1200, 1500],落到新底部后仍能交还控制权(再往上滚胶囊照常出现)。修正前在目标数组处红。
    • 提交:2b0147689 修复:动画期间内容变高时把回到底部动画重新对准新底部
    • 文档:ADR 第 3 条新增这条口径,决策记录同步。
  • 3. 世代号只在复位 effect 里推进,切换项目时守卫不即时生效(原标 bug · medium)

    • 现状(修改前):controller/useDirectProjectChatController.ts 里 projectPathRef.current = projectPath 是渲染期赋值,
      而 historyLoadTokenRef.current += 1 在 [projectPath] 的复位 effect 里;loadEarlierHistory 的守卫是「启动时捕获的世代号 === 当前世代号」。
    • 问题:React 的 passive effect 走宏任务、promise 续体走微任务,于是「切换提交完成、复位 effect 还没跑」之间有一个窗口;
      旧读取在这个窗口里落地时世代号还是旧的,守卫放行,mergeHistoryItems / setHistoryHasMore / 覆盖 historyOldestItemIdRef 照常执行。
      review 说终态不会被弄坏(复位 effect 随后清状态、订阅复位清条目)——这点我同意,但那条白跑的跨项目读取与瞬时脏状态是真的。
    • 修复:按 review 采纳——推进移到渲染期,与 projectPathRef 同一处、只在路径真的变化时推进;复位 effect 不再推进。
    • 「删掉 effect 里那次推进」这半步我也单独核过安全性:同一提交里子组件(DirectProjectConversation 的「填充视口」effect)的判据
      [historyError, historyHasMore, historyLoading, loadEarlier, turns] 都不变——订阅状态复位自己也是 effect、turns 来自 useMemo、
      loadEarlier 是 useCallback([]) 的稳定回调——所以这个窗口里不可能新起一次「拿旧游标读新项目」的读取,去掉那次推进不会放过它。
    • 验证的限界(如实说明):这个窗口依赖 React 调度,act 会把 effect 与断言放进同一作用域冲掉,jsdom 下构造不出「旧读取先于复位 effect 落地」的确定性用例,
      因此本条没有新增红灯用例,只有既有的控制器世代用例(切项目、A→B→A)保持全绿 + 上面的依赖分析。
      要硬证据只能真机:慢读取期间切项目,看是否还多打一次跨项目读取。
    • 提交:0ae94caf1 修复:更早历史的世代号在渲染期推进,切换项目的守卫不等 effect
    • 文档:ADR 第 1 条补上推进时机,决策记录同步(含安全性依据与验证限界)。
  • 4. 「加载期间的锚点冻结被 onScroll 覆盖」(原标 bug · medium)——你决定不修

    • 现状:加载期间 compensateLayout 不刷新 preserveAnchorRef(只在 !historyLoadingRef.current 时刷新),但 onScroll:306 无条件刷新。
    • review 的机制链是「restoreTurnAnchor 写 scrollTop → 触发滚动事件 → onScroll 把加载行高度烘进 offset → 加载行卸载后最后一次还原偏移了一行的高度」。
      这一步算不平:加载行挂载时这次补偿先用冻结锚点把块还原到原偏移,由这次写入触发的滚动事件再读回来仍是同一个偏移
      (offset = 块的屏幕 top − 列表顶边,还原刚刚把它对齐),不会是「加载行高度 + 原偏移」;而插入加载行本身不改变 scrollTop,不会自己产生滚动事件。
      我没能找到一条能稳定产生「少补一行高度」的路径,所以把它判为存疑而不是照改。
    • 反过来,按建议改成 else if (!historyLoadingRef.current) 有明确代价:加载期间用户真的滚动时锚点不再更新,
      下一次补偿(加载行挂载 / 卸载、历史合并)会把用户拉回滚动之前的位置——慢加载叠加长会话时表现为「我明明滚了,它自己弹回去」。
      「冻结」该冻结的是补偿自己造成的位移,不该顺手冻掉用户意图。
    • 我的建议(需要你定):保持现状;若你想再收紧,把「补偿自己写 scrollTop 触发的滚动事件」显式标记出来(例如补偿前后同步一次锚点、
      或给这类写入挂一个与 programmaticScrollRef 同类的标记),而不是用 historyLoading 这个更粗的条件关掉刷新。
      真机验收里如果真能看到「加载行消失时跳一行」,再做这一步也不迟。
    • 决定(2026-10-02):你说「即使成立,也就一行差异,可忽略」,所以不改实现,保持现状。
  • 5. readTopVisibleTurnAnchor 每个滚动帧全表量测(原标 performance · low)——按你的要求只加 TODO 注释,不改实现

    • 现状:conversationScrollAnchor.ts 的 readTopVisibleTurnAnchor 按文档顺序遍历缓存的块集合、跳过不可见块(含被折进收起 <details> 的块),
      遇到第一条 bottom > 列表顶边 的块才停;调用点是 onScroll(离底时)与每次 compensateLayout。
    • 代价:深历史下确实是 O(锚点之前的块数) 次 getBoundingClientRect(),但同一帧里第一次调用之后布局已经干净,
      后续调用不会各自再触发一次 reflow,所以「每帧几百次强制布局」这个说法偏重;量级上是几百次廉价矩形读取,属于 low,不是振荡源。
    • review 的两个修法:① rAF 节流——滚动事件本身就按帧派发,同一帧再去重收益极小(真正会一帧多次的是平滑动画的中间帧);
      ② 「只在补偿前才读」——但 onScroll 里读锚点就是为了在补偿发生之前拿到「用户此刻在哪儿」,去掉它,前插之后就没有可还原的基准了。两者都省不掉那次遍历。
    • 真正能省掉遍历的做法(要动不变量,所以留给你):按「块在文档顺序里位置单调递增」做二分、或从上次锚点位置续扫。
      陷阱是 review 没提到的:被折进收起 <details> 的块在真实浏览器里矩形全 0,而它们同样带 data-turn-key / data-block-key、也在候选集合里,
      这些 0 会打断单调性;二分前必须先定「隐藏块怎么参与」(现在的线性扫描天然跳过它们)。要给锚点集合加「只收可见块」的规则,
      代价是每次子元素变化都要先量一遍,等于把省下的遍历又搬回来。
    • 结论:不改实现,只在 conversationScrollAnchor.ts 的 readTopVisibleTurnAnchor 循环上方留一条 // TODO(perf): 注释,把上面的量测口径、两个建议修法为何省不掉遍历、二分被 0 矩形打断、以及首选的「缓存上次命中块 + 附近续扫」方案写清楚,便于真机出问题时就地接手。
    • 提交:TODO 注释随本次变更一起提交(仅注释,无行为变化)。
    • 真机验收:若超长历史里量到滚动手感问题,按注释里的「缓存命中块 + 附近续扫」做,不要退回 rAF 节流(滚动事件本就按帧派发)。
本文件是临时工作文件(不提交;按你的要求放在 .git/info/exclude 里)。 本轮 review 的 5 条我都在当前代码上核过:1、2、3 修好并各自单独提交;4 你决定不修(一行差异可忽略);5 按你的要求只加 TODO 注释、不改实现。 (你本地已有的 .env 改动我没碰。) - [x] 1. 历史加载失败行的重试按钮嵌在 `role="alert"` 里(原标 other · low) - 现状(修改前):`apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectHistoryRow.tsx:40-44` 的 `DirectProjectHistoryErrorRow` 把整行做成 `<p ... role="alert">`,重试 `<button>` 是它的子节点。 - 问题:`role="alert"` 隐含 `aria-live="assertive"` + `aria-atomic="true"`,整行会被当成一条断言性播报; 可交互控件嵌在 live region 内部,读屏不一定会把它当可聚焦按钮播报,用户交互也落在播报区里。review 的判断成立。 - 修复:容器改成普通 `<div>`(布局类原样保留),`role="alert"` 只包住「加载更早对话失败」文案,重试按钮成为 live region 之外的兄弟; 仍是同一行、同样的样式,重试仍是按钮移除后唯一的手动出路。 - 用例:`DirectProjectConversation.test.tsx` 新增一例,断言 `role="alert"` 节点不包含重试按钮、文案仍在 alert 内、点击仍触发重试。修正前该断言红。 - 提交:`8f91227d1` 修复:历史加载错误行的重试按钮移出断言式 live region - 文档:ADR 第 1 条的错误行口径同步为「alert 只包文案、按钮在 live region 之外」。 - [x] 2. 平滑滚动期间内容变高,`programmaticScrollRef` 卡住不交还(原标 bug · medium) - 现状(修改前):`components/DirectProjectConversation/useConversationScroll.ts` 的 `compensateLayout` 在 `programmaticScrollRef.current` 为真时直接 `return`; `scrollToBottom` 用点击那一刻的 `list.scrollHeight` 作为平滑滚动目标;标记只在「贴底」的那次滚动事件、或滚轮 / 触摸 / 键盘接手时交还。 - 问题:流式正文、图片撑开会让 `scrollHeight` 在动画途中继续增长,动画停在旧目标上,`nearBottom` 永远不成立 → 标记永远为真 → 布局补偿整段跳过(列表不再跟随新内容),而 `scrollToBottom` 已把 `atBottom` 置真、胶囊按「已贴底」隐掉: 用户停在底部之上,既没有指示也没有自动跟随,只能靠手动滚一下才解开。review 的判断成立。 - 修复:程序化滚动期间仍然不写 `scrollTop`(写一次就会取消动画),但内容变高时把动画目标重新对准新的底部 (`compensateLayout` 里改调 `scrollListToBottom(list, 'smooth')`);动画落到真正的底后照常在贴底事件里交还。 没有采纳 review 的另一半备选 `scrollend`:它在部分 WebView / Safari 版本上不可用,而且把目标重对准底部之后动画自己会发贴底事件,不需要另加监听。 - 用例:`useConversationScroll.test.tsx` 新增一例——点击回到底部后 `scrollHeight` 1200→1500 并触发一次布局变化, 断言动画目标由 `[1200]` 变为 `[1200, 1500]`,落到新底部后仍能交还控制权(再往上滚胶囊照常出现)。修正前在目标数组处红。 - 提交:`2b0147689` 修复:动画期间内容变高时把回到底部动画重新对准新底部 - 文档:ADR 第 3 条新增这条口径,决策记录同步。 - [x] 3. 世代号只在复位 effect 里推进,切换项目时守卫不即时生效(原标 bug · medium) - 现状(修改前):`controller/useDirectProjectChatController.ts` 里 `projectPathRef.current = projectPath` 是渲染期赋值, 而 `historyLoadTokenRef.current += 1` 在 `[projectPath]` 的复位 effect 里;`loadEarlierHistory` 的守卫是「启动时捕获的世代号 === 当前世代号」。 - 问题:React 的 passive effect 走宏任务、promise 续体走微任务,于是「切换提交完成、复位 effect 还没跑」之间有一个窗口; 旧读取在这个窗口里落地时世代号还是旧的,守卫放行,`mergeHistoryItems` / `setHistoryHasMore` / 覆盖 `historyOldestItemIdRef` 照常执行。 review 说终态不会被弄坏(复位 effect 随后清状态、订阅复位清条目)——这点我同意,但那条白跑的跨项目读取与瞬时脏状态是真的。 - 修复:按 review 采纳——推进移到渲染期,与 `projectPathRef` 同一处、只在路径真的变化时推进;复位 effect 不再推进。 - 「删掉 effect 里那次推进」这半步我也单独核过安全性:同一提交里子组件(`DirectProjectConversation` 的「填充视口」effect)的判据 `[historyError, historyHasMore, historyLoading, loadEarlier, turns]` 都不变——订阅状态复位自己也是 effect、`turns` 来自 `useMemo`、 `loadEarlier` 是 `useCallback([])` 的稳定回调——所以这个窗口里不可能新起一次「拿旧游标读新项目」的读取,去掉那次推进不会放过它。 - 验证的限界(如实说明):这个窗口依赖 React 调度,`act` 会把 effect 与断言放进同一作用域冲掉,jsdom 下构造不出「旧读取先于复位 effect 落地」的确定性用例, 因此本条**没有新增红灯用例**,只有既有的控制器世代用例(切项目、A→B→A)保持全绿 + 上面的依赖分析。 要硬证据只能真机:慢读取期间切项目,看是否还多打一次跨项目读取。 - 提交:`0ae94caf1` 修复:更早历史的世代号在渲染期推进,切换项目的守卫不等 effect - 文档:ADR 第 1 条补上推进时机,决策记录同步(含安全性依据与验证限界)。 - [x] 4. 「加载期间的锚点冻结被 `onScroll` 覆盖」(原标 bug · medium)——你决定不修 - 现状:加载期间 `compensateLayout` 不刷新 `preserveAnchorRef`(只在 `!historyLoadingRef.current` 时刷新),但 `onScroll:306` 无条件刷新。 - review 的机制链是「`restoreTurnAnchor` 写 `scrollTop` → 触发滚动事件 → `onScroll` 把加载行高度烘进 `offset` → 加载行卸载后最后一次还原偏移了一行的高度」。 这一步算不平:加载行挂载时这次补偿先用冻结锚点把块还原到原偏移,由这次写入触发的滚动事件再读回来仍是**同一个偏移** (`offset = 块的屏幕 top − 列表顶边`,还原刚刚把它对齐),不会是「加载行高度 + 原偏移」;而插入加载行本身不改变 `scrollTop`,不会自己产生滚动事件。 我没能找到一条能稳定产生「少补一行高度」的路径,所以把它判为存疑而不是照改。 - 反过来,按建议改成 `else if (!historyLoadingRef.current)` 有明确代价:加载期间用户真的滚动时锚点不再更新, 下一次补偿(加载行挂载 / 卸载、历史合并)会把用户拉回滚动之前的位置——慢加载叠加长会话时表现为「我明明滚了,它自己弹回去」。 「冻结」该冻结的是补偿自己造成的位移,不该顺手冻掉用户意图。 - 我的建议(需要你定):保持现状;若你想再收紧,把「补偿自己写 `scrollTop` 触发的滚动事件」显式标记出来(例如补偿前后同步一次锚点、 或给这类写入挂一个与 `programmaticScrollRef` 同类的标记),而不是用 `historyLoading` 这个更粗的条件关掉刷新。 真机验收里如果真能看到「加载行消失时跳一行」,再做这一步也不迟。 - 决定(2026-10-02):你说「即使成立,也就一行差异,可忽略」,所以**不改实现**,保持现状。 - [x] 5. `readTopVisibleTurnAnchor` 每个滚动帧全表量测(原标 performance · low)——按你的要求只加 TODO 注释,不改实现 - 现状:`conversationScrollAnchor.ts` 的 `readTopVisibleTurnAnchor` 按文档顺序遍历缓存的块集合、跳过不可见块(含被折进收起 `<details>` 的块), 遇到第一条 `bottom > 列表顶边` 的块才停;调用点是 `onScroll`(离底时)与每次 `compensateLayout`。 - 代价:深历史下确实是 O(锚点之前的块数) 次 `getBoundingClientRect()`,但**同一帧里第一次调用之后布局已经干净**, 后续调用不会各自再触发一次 reflow,所以「每帧几百次强制布局」这个说法偏重;量级上是几百次廉价矩形读取,属于 low,不是振荡源。 - review 的两个修法:① rAF 节流——滚动事件本身就按帧派发,同一帧再去重收益极小(真正会一帧多次的是平滑动画的中间帧); ② 「只在补偿前才读」——但 `onScroll` 里读锚点就是为了在补偿发生**之前**拿到「用户此刻在哪儿」,去掉它,前插之后就没有可还原的基准了。两者都省不掉那次遍历。 - 真正能省掉遍历的做法(要动不变量,所以留给你):按「块在文档顺序里位置单调递增」做二分、或从上次锚点位置续扫。 陷阱是 review 没提到的:被折进**收起** `<details>` 的块在真实浏览器里矩形全 0,而它们同样带 `data-turn-key` / `data-block-key`、也在候选集合里, 这些 0 会打断单调性;二分前必须先定「隐藏块怎么参与」(现在的线性扫描天然跳过它们)。要给锚点集合加「只收可见块」的规则, 代价是每次子元素变化都要先量一遍,等于把省下的遍历又搬回来。 - 结论:不改实现,只在 `conversationScrollAnchor.ts` 的 `readTopVisibleTurnAnchor` 循环上方留一条 `// TODO(perf):` 注释,把上面的量测口径、两个建议修法为何省不掉遍历、二分被 0 矩形打断、以及首选的「缓存上次命中块 + 附近续扫」方案写清楚,便于真机出问题时就地接手。 - 提交:`TODO` 注释随本次变更一起提交(仅注释,无行为变化)。 - 真机验收:若超长历史里量到滚动手感问题,按注释里的「缓存命中块 + 附近续扫」做,不要退回 rAF 节流(滚动事件本就按帧派发)。
k88936 merged commit d90f4fdfdc into master 2026-10-02 23:22:32 +08:00
k88936 deleted branch feat/smoother-dialog-history 2026-10-02 23:22:32 +08:00
Sign in to join this conversation.