文档:前插锚点改用稳定块身份

锚点契约从「回合 key + 块序号 + 偏移」改为「回合 key + 块身份(data-block-key)+ 偏移」,写明 running→finished 收口后块序号整体后移、序号锚点会解析到隔壁块的原因。

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

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

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

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 20:12:35 +08:00
parent a589dad003
commit 9f7c2e318c
2 changed files with 12 additions and 5 deletions
@@ -24,13 +24,16 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
- 失败:挂起自动加载,列表顶部保留一行 `role="alert"` 的「加载更早对话失败 · 重试」,重试是可聚焦按钮;**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。
- 一次加载与它所属的**世代**绑定:切换项目或新起一次读取都推进世代号(`historyLoadTokenRef`),旧世代落地时整段失效——不合并条目、不写游标、不关加载态。只比项目路径不够:A→B→A 之后在飞的旧读取又落回同一个路径,原守卫放行,会把新一代的加载行与 `historyLoadingRef` 这道并发闸门一起改掉。
### 2. 前插锚定:回合 key + 偏移,关掉浏览器原生锚定
### 2. 前插锚定:回合 key + 块身份 + 偏移,关掉浏览器原生锚定
- 锚点 = 顶部可见回合的 `data-turn-key` + 该回合内的块序号 + 相对列表顶边的像素偏移。
- 合并更早历史前记录锚点,合并后按同一 key/序号把偏移还原,较早内容出现在上方而用户正在读的位置不动。
- 锚点 = 顶部可见块的 `data-turn-key` + `data-block-key` + 相对列表顶边的像素偏移。
- 合并更早历史前记录锚点,合并后按同一 key/块身份把偏移还原,较早内容出现在上方而用户正在读的位置不动。
- 块身份来自 `DirectChatBlock.key`(条目块是 `${回合 key}:${条目 itemId}`,本地说明块是 `messageId`),只要求**在同一回合内唯一**。
- 不按块序号定位:`DirectProjectTurn` 的 `renderTurnProcess` 对运行中的回合把过程块**平铺**、对已结束的回合把它们折进一个 `<details>`,收口时整个回合的块序号都会后移(`[用户, 工具组1, 工具组2]` → `[用户, 执行过程, 工具组1, 工具组2, 终态正文, 终态文案]`),序号锚点会解析到隔壁块并按错误基准写 `scrollTop`,表现为一帧跳动。
- 锚点块没有布局盒时(被折进**收起**的 `<details>`、或已从列表移除)`restoreTurnAnchor` 返回 false:调用方据此放弃这次补偿,拿当前可见位置重新起锚,不写 `scrollTop`(不回跳,也不假装对齐)。同理,读锚点时跳过没有布局盒的块——它既不是「用户正在读的位置」,也还原不出来。
- 列表上加 `overflow-anchor: none`,关掉浏览器原生 scroll anchoring,保证只有一套补偿在跑(不做「两套补偿交替生效」)。
- 一次加载期间(`historyLoading` 为真)所有补偿——ResizeObserver 回调、加载行挂载、历史合并——都还原同一个冻结锚点;加载结束再刷新锚点。
- 块集合(列表里所有带 `data-turn-key` 的节点)在列表子元素没变时复用同一份缓存,缓存由子元素增删(与 `ResizeObserver` 重订阅共用同一个 `MutationObserver`)失效:否则一次滚动帧里读锚点、还原锚点会各查一遍整份列表,长会话下就是 O(块数) 的滚动抖动。
- 块集合(列表里所有同时带 `data-turn-key` 与 `data-block-key` 的节点,即所有可锚定的块)在列表子元素没变时复用同一份缓存,缓存由子元素增删(与 `ResizeObserver` 重订阅共用同一个 `MutationObserver`)失效:否则一次滚动帧里读锚点、还原锚点会各查一遍整份列表,长会话下就是 O(块数) 的滚动抖动。
### 3. 回到底部胶囊
@@ -69,11 +72,13 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
5. **平滑滚动期间照常处理滚动事件与布局补偿**:程序化滚动会被应用自己的补偿打断(第一次写 `scrollTop` 即取消动画),用户点了「回到底部」也停在半路;因此改为滚动期间冻结这两条路径。
6. **自动重试失败的历史加载**:弱网下会反复打接口,且用户看不出到底在重试还是在挂起;改为挂起 + 内联重试行。
7. **用 `key={projectPath}` 让 React 重建会话列表**:改动最小,但整份消息列表连同加载行的 150ms 延迟计时一起重建,而且「同一个项目重开」也会被当成新会话;改用显式身份信号复位,重建与否与「换没换会话」解耦。
8. **锚点存块序号(`index`)而不是块身份**:序号在「回合 running→finished」时会因过程被折进 `<details>` 而整体后移,解析到隔壁块;改成稳定块身份只多一层 `data-block-key`,且块不可见仍要单独判失败,所以「失效锚点」省下的那点改动不值得留下错位。
9. **锚点块被折进收起的 `<details>` 时把还原改成找同回合内其它可见块**:会按另一条消息的基准写 `scrollTop`,比放弃这次补偿更糟;放弃只表现为不自动对齐,不回跳。
## 影响
- 历史加载失败不再只写顶部状态行,而是落到列表里的内联错误行;顶部状态行仍保留首屏读取失败等其它用途。
- 滚动是表现层行为,正式状态仍在后端投影与运行态事件;本 ADR 不新增领域概念。
- 阈值(触顶 24px、贴底 48px、spinner 150ms)是可按手感调整的常量,集中放在 `components/DirectProjectConversation/conversationScrollPolicy.ts`。
- 验收:纯函数与 jsdom 组件测试覆盖阈值、锚点还原、加载/错误行、胶囊文案与显隐;滚动观感(顶部加载圈、胶囊出现与消失、底部展开回贴、历史前插不跳、切项目后首屏贴底且不误报「有新回复」)必须真机手动验收——jsdom 没有布局。
- 验收:纯函数与 jsdom 组件测试覆盖阈值、锚点还原(含收口后按块身份仍指向同一块、锚点块被折叠隐藏时放弃还原)、加载/错误行、胶囊文案与显隐;滚动观感(顶部加载圈、胶囊出现与消失、底部展开回贴、历史前插不跳、长回合收口时视口不跳、切项目后首屏贴底且不误报「有新回复」)必须真机手动验收——jsdom 没有布局。
- 明确的后续项(不在本次范围):`PlanningChatView` 与 `App.tsx` 遗留 `message-history-more` 路径的同款改造、未读条数徽标、Playwright 端到端。
@@ -12,6 +12,8 @@
- 验证(2026-10-02 追加,换会话复位):`useConversationScroll` 新增两例换会话用例(新会话首屏贴底且不出现胶囊、切会话后在 B 里往上滚只显示「回到底部」而不是「有新回复」),修正前确实红、修正后绿;chat 范围用例全绿。
- 追加(2026-10-02,review 两处内部缺陷的修正):①更早历史读取的守卫从「项目路径相等」改成「世代号相等」(`historyLoadTokenRef`,切项目与每次读取各推进一格)——A→B→A 之后在飞的旧读取又落回同一路径,原守卫会放行,把新一代的加载态、并发闸门与游标一起改掉;现在非最新一次读取落地时整段丢弃。②锚点的块集合按列表缓存(`WeakMap`),由已有的子元素 `MutationObserver` 在同一处 `invalidateTurnBlocks` 失效——原来一个滚动帧里读锚点、还原锚点各查一遍整份列表,长会话下是 O(块数) 的 DOM 查询。
- 验证(2026-10-02 追加,review 修正):新增一例控制器用例(驱动 A→B→A 且同路径新读取在飞,修正前在「旧读取落地」处红)与两例锚点缓存用例(连续收集只查一次 DOM、子元素变化并失效后重新收集);chat 范围用例全绿,`npm run ai-game-creator-shell:typecheck`、`eslint --max-warnings 0`、`npm run check:encoding`、`git diff --check` 通过。
- 追加(2026-10-02,锚点改用稳定块身份):review 第 3 条(`findTurnBlock` 按块序号定位)成立,采纳「换一套块身份」。锚点从 `{回合 key, 块序号, 偏移}` 改为 `{回合 key, 块身份, 偏移}`,块身份即 `DirectChatBlock.key`(`${回合 key}:${条目 itemId}` / 本地说明 `messageId`),只要求同一回合内唯一;展示层给每个可锚定块加 `data-block-key`(`DirectProjectTurn` 的正文块与终态文案、折进 `<details>` 的过程包装块、`ToolCallGroup`、`AgentReasoning` 各自透传),`conversationScrollAnchor.ts` 的收集选择器因此收敛为 `[data-turn-key][data-block-key]`。原因:`renderTurnProcess` 对运行中的回合平铺过程块、对已结束的回合折进一个 `<details>`,收口时整个回合的块序号后移一格,序号锚点会解析到隔壁块并按错误基准写 `scrollTop`。同时补上 review 指出、原方案漏掉的一半:锚点块没有布局盒(被折进收起的 `<details>`)时读锚点跳过它、`restoreTurnAnchor` 判为失败,调用方放弃这次补偿并重新起锚——不回跳,也不按别的块硬对齐。
- 验证(2026-10-02 追加,稳定块身份):锚点纯函数用例改为按块身份构造(`buildList` 传 `[回合 key, 块身份]`),新增「回合收口后块序号整体后移,块身份仍指向同一块且还原成功」「收起的 `<details>` 里的块没有布局盒:读锚点跳过、还原返回 false 且不写 `scrollTop`」;组件层新增用例断言每个带 `data-turn-key` 的块都有 `data-block-key`、同回合内块身份唯一、回合 running→finished 后同一块身份仍在。修正前锚点用例在「收口后按身份定位」处红。
## 2026-10-01 Web、后台与 AGC 一键联调