Merge remote-tracking branch 'origin/master' into fix/res-edit-error-handling
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m4s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m9s
Project CI / Backend tests (pull_request) Successful in 4m39s
Project CI / Frontend tests (pull_request) Successful in 2m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m20s
Project CI / Repository checks (pull_request) Successful in 3m2s
Project CI / Native shell tests (pull_request) Successful in 5m37s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m4s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m9s
Project CI / Backend tests (pull_request) Successful in 4m39s
Project CI / Frontend tests (pull_request) Successful in 2m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m20s
Project CI / Repository checks (pull_request) Successful in 3m2s
Project CI / Native shell tests (pull_request) Successful in 5m37s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -58,6 +58,7 @@
|
||||
- [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。
|
||||
- [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。
|
||||
- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`error` 字段就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,无字段变体用固定文案、带载荷分支先 `as` 取自己的具名载荷类型(可枚举细分再 `switch (payload.reason)` 在类型化枚举上分流),系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。
|
||||
- [DirectProject 对话滚动与历史自动加载](./adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md):删掉「显示更早的对话」按钮改为自动加载 + 内联加载/错误行,回合 key 冻结锚点前插不跳,底部居中「回到底部 / 有新回复」胶囊,折叠展开按跟随状态贴底或保锚点。
|
||||
- [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。
|
||||
- [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。
|
||||
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
|
||||
|
||||
@@ -35,7 +35,7 @@ AGC 项目开发聊天框当前同时从多处取数据:Direct 回合事件(
|
||||
- 执行通道断开同样是失败终态,也必须带 `failure`:连接级故障(app-server 进程退出 / stdout 流断 / JSON 行越界)与回合事件通道关闭都算,`kind="transport-failed"`、`message` 用宿主当场写下的那份诊断(含 `exitStatus` 与 stderr 摘要,已脱敏截断)。宿主在检测到连接终止的第一时间把这条事实记到本回合的执行适配器上,终态判定再从适配器读:执行适配器的看门狗盯着同一个 `closed` 标志,用调用点局部变量会输给这场调度竞争,失败原因就只剩日志、界面只会看到"本轮已结束"。判据是"适配器是否已由宿主主动关闭"——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)走的是同一个 `TransportClosed` 事件,但这些不算失败。
|
||||
- 终态由**事实**判定,不由收尾阶段反推:判定按优先级取「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段的账本读不出来时才用交付报告」,**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾会把 ledger 阶段推成 `Interrupted`,让阶段决定终态就会把已经失败的一轮讲成"已结束"。模型自报失败(原生 `turn/completed.status="failed"` 的 `error`,带 `codexErrorInfo` 分类)不为载荷新增输入字段:宿主把原生 `error` 的 `codexErrorInfo` 解析成 typed 分类后当作本回合的错误结果,走同一条通道进载荷;交付报告只说明"收束到哪一步",不得顶掉原因。
|
||||
- 回合失败在宿主内部是 **typed** 的:`agent/direct_turn_error.rs` 的 `DirectTurnError` 每个变体自带字段(并发拒绝带两个 invocation id、模型失败带分类、超时带撞的是哪条上限、通道断开带宿主诊断),**调用级拒绝**(这一轮没有开始)与**回合级失败**(这一轮已开始并被判失败)不共用判据,分流只认 `is_turn_failure()`。判据不再对原因文本做子串匹配,`LlmError` 只在平台层入口出现一次(`DirectTurnError::from_model_call`)。线上载荷 `{kind, message}`、命令边界字符串与 CLI 返回值都由这一个出口投影出来,Rust 侧任何地方都不再解析它们。
|
||||
- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。
|
||||
- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。(**按 2026-10-02 ADR 补充**:更早历史的触发时机、加载指示、失败重试与视口锚定改由 [`【ADR】DirectProject对话滚动与历史自动加载-2026-10-02`](./【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md) 规定;本条的分页锚点口径与连拉上限仍然有效。)
|
||||
- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。
|
||||
- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。
|
||||
- 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 【ADR】DirectProject对话滚动与历史自动加载-2026-10-02
|
||||
|
||||
状态:已接受
|
||||
|
||||
## 背景
|
||||
|
||||
DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/`)当前的滚动与历史体验有三个问题:
|
||||
|
||||
1. 更早历史要靠常驻按钮「显示更早的对话」(`.message-history-more`)手动拉,但 `handleScroll` 里已经有一条「滚到顶部 24px 以内就自动加载」的路径——按钮是半冗余的第二入口,还没有任何加载中的视觉反馈(`historyLoadingRef` 是 ref,渲染不出来)。
|
||||
2. 用户滚上去之后没有任何「回到最新」的入口,只能自己一直滚到底。
|
||||
3. 展开一折叠块(`<details>` 或 `[aria-expanded]`)时浏览器保持 `scrollTop` 不变,新展开的正文长在视口下方,用户必须再滚一次才能看到;反过来在底部展开时内容直接顶出可视区。
|
||||
|
||||
同一目录下的 `PlanningChatView` 复用 `.project-chat-message-list` / `.project-chat-conversation` 两个类名,所以任何滚动容器结构改动都必须留在 DirectProject 本地,不能改公共类。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. 更早历史:删掉按钮,自动加载 + 内联加载行
|
||||
|
||||
- 删除「显示更早的对话」按钮与 `DirectProjectConversation` 里的条件渲染;`.message-history-more` 样式**保留**——策划对话(`PlanningChatView`)仍在用同一套类名与按钮,等它一起改造时再删(见「影响」的后续项)。
|
||||
- 触发一(滚动):`scrollTop <= 24` 且 `historyHasMore` 且不在加载中且没有失败记录时自动加载。
|
||||
- 触发二(填充视口):首帧之后内容填不满视口(`scrollHeight <= clientHeight`)时继续加载,直到填满或 `hasMore=false`;不允许出现「历史比视口短、又没有按钮」的死局。
|
||||
- 两个触发都不越过既有的首屏订阅锚点 `lastCompletedItemId`;一次加载仍最多连拉 5 页(口径见 [`【ADR】DirectProject对话历史单一事实源-2026-09-16`](./【ADR】DirectProject对话历史单一事实源-2026-09-16.md))。
|
||||
- 加载中在列表最上方(比最旧一条回合更靠上)挂载一行 `role="status"`、`aria-live="polite"` 的「正在加载更早的对话」,带旋转圈;延迟 150ms 才显示,加载结束即卸载。它按需挂载,靠位置补偿(见第 2 条)保证下面的消息不跳。
|
||||
- 失败:挂起自动加载,列表顶部保留一行内联错误行——`role="alert"` 只包住「加载更早对话失败」文案本身,重试是可聚焦按钮、留在 live region 之外(assertive + atomic 的 live region 里不放交互控件);**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。
|
||||
- 一次加载与它所属的**世代**绑定:切换项目或新起一次读取都推进世代号(`historyLoadTokenRef`),旧世代落地时整段失效——不合并条目、不写游标、不关加载态。只比项目路径不够:A→B→A 之后在飞的旧读取又落回同一个路径,原守卫放行,会把新一代的加载行与 `historyLoadingRef` 这道并发闸门一起改掉。换项目的推进放在**渲染期**(与 `projectPathRef` 同一处),不放在复位 effect 里:passive effect 走宏任务、promise 续体走微任务,旧读取可能在「切换提交完成、复位 effect 还没跑」的窗口里落地,那时世代号还是旧的,守卫会放行。
|
||||
|
||||
### 2. 前插锚定:回合 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` 与 `data-block-key` 的节点,即所有可锚定的块)在列表子元素没变时复用同一份缓存,缓存由子元素增删(与 `ResizeObserver` 重订阅共用同一个 `MutationObserver`)失效:否则一次滚动帧里读锚点、还原锚点会各查一遍整份列表,长会话下就是 O(块数) 的滚动抖动。
|
||||
|
||||
### 3. 回到底部胶囊
|
||||
|
||||
- 列表底部居中的悬浮胶囊(`sticky`),跟着滚动容器走、不随内容滚走。
|
||||
- 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。
|
||||
- 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。
|
||||
- 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。
|
||||
- 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。
|
||||
|
||||
### 4. 跟随最新与折叠展开
|
||||
|
||||
- 距底部 ≤ 48px 视为「在底部」,即跟随最新;用户手动滚回去(进入阈值)就自动恢复跟随,不需要额外开关。
|
||||
- 用 `ResizeObserver` 观察列表的直接子元素(`MutationObserver` 负责在子元素变化时重订阅)——任何高度变化:
|
||||
- 跟随时 → 贴底;
|
||||
- 不跟随时 → 还原顶部可见回合锚点(用户正在读的那条折叠头留在原位);若刚刚展开的那个块头部仍然可见,则按展开前记录的屏幕位置把它冻结,并把新展开的正文露出来:正文底边超出视口就只滚到刚好露出底边,正文比视口还高则对齐正文顶部;
|
||||
- 收起(正文高度归零)不额外滚动,只冻结头部。
|
||||
- 新一回合开始(`turnInFlight` 由假转真)时强制恢复跟随并贴底,替代原先由视图直接写 `shouldFollowLatestRef` 的做法。
|
||||
|
||||
### 5. 结构与样式
|
||||
|
||||
- 滚动所有权(列表 ref、跟随最新、ResizeObserver、锚点还原、胶囊可见性)搬进 `DirectProjectConversation` 这一层;`DirectProjectChatView` 只传加载状态与回调,不再持有 `messagesRef` / `shouldFollowLatestRef` / `handleScroll`。
|
||||
- 控制器对渲染层暴露可渲染的 `historyLoading` 与 `historyError`(外加 `retryEarlierHistory`),取代只存在于 ref 里的加载标志。
|
||||
- 新增元素全部用内联 Tailwind 工具类,不改 `styles.css`;列表本身仍保持 `message-list project-chat-message-list` 类名不变,只追加 `overflow-anchor` 工具类。
|
||||
|
||||
### 6. 换会话时复位滚动所有权
|
||||
|
||||
- 列表容器在切项目时**不会重挂载**:`DirectProjectChatView` 在 `App.tsx` 只有一处渲染、没有 `key`,内部的 `DirectProjectConversation` 也没有 `key`,而 `useConversationScroll` 的跟随最新 / `atBottom` / `hasNewReply` / 前插锚点 / 折叠头 / 程序化滚动标记只在挂载时初始化一次。控制器自己按项目重置了历史状态,滚动所有权却一直漏着。
|
||||
- 症状:在项目 A 往上滚过再切到 B——① B 的首屏不贴底,用户得自己往下滚;② B 的第一批回合会在「不跟随」分支被算成新内容,胶囊在新项目上直接显示「有新回复 · 回到底部」,可用户根本没在 B 里离开过底部。
|
||||
- 决策:把会话身份(`conversationKey`,DirectProject 传项目路径)作为**显式信号**传给 `useConversationScroll`;身份变化时复位上述滚动所有权状态、把终态指纹同步成新会话内容、再贴底。不改变组件生命周期,同一个项目重开(身份不变)也不会被当成新会话;将来策划对话复用同一个 hook 时用的是同一套信号。
|
||||
|
||||
## 备选方案与取舍
|
||||
|
||||
1. **保留按钮 + 只加自动加载**:加载中仍靠按钮做唯一反馈,且删掉按钮后失败路径没有补救入口;按钮本身与滚动自动加载重复。
|
||||
2. **在列表外面套一层 viewport 做浮层定位**:`PlanningChatView` 共用同一套容器规则,且工作台里 `.project-chat-conversation` 是 `display: block` + `height: 100%` 几何,套一层就会让 `height: 100%` 的列表塌成内容高度;改公共类会连带策划对话。改用列表内的 `sticky` 胶囊,零结构改动。
|
||||
3. **只依赖原生 CSS scroll anchoring**:前插能免费对齐,但做不到「跟随时展开要贴底」,也无法在加载期间冻结同一套锚点;因此显式补偿 + 关闭原生锚定。
|
||||
4. **展开后总是把正文滚进视口**:对正文比视口矮的折叠块会把画面大幅上移,打断正在读历史的用户;采用「跟随时贴底 / 否则冻结折叠头 + 只滚到刚好露出新展开正文的最小位移」。
|
||||
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 没有布局。
|
||||
- 明确的后续项(不在本次范围):`PlanningChatView` 与 `App.tsx` 遗留 `message-history-more` 路径的同款改造、未读条数徽标、Playwright 端到端。
|
||||
@@ -17,6 +17,28 @@
|
||||
- 改动范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`project/resource_editor/error.rs`(新)、`agent/tool/error.rs`、`agent/tool/create_or_derive_resource/error.rs`、`agent/tool/remove_background/error.rs`、`agent/direct_tool_bridge.rs`、`assets.rs`、`docs/project-memory/shared-memory/pitfalls.md`。
|
||||
- 验证:`cargo check --bin genarrative-ai-game-creator-shell --tests` 通过;`cargo test --bin genarrative-ai-game-creator-shell -- project::resource_editor --test-threads=1` 66 passed(并行跑会有一批 TCP fixture 用例因争用超时,串行全绿,与本次改动无关);`-- agent::tool:: agent::direct_tool_bridge` 47 passed;`npm run check:encoding` 5111 files;`git diff --check` 干净;`cargo fmt` 已跑。
|
||||
- 关联:`pitfalls.md`「远端资源编辑终态必须指出唯一出口」。
|
||||
|
||||
## 2026-10-02 DirectProject 对话:更早历史自动加载、前插锚定与回到底部胶囊
|
||||
|
||||
- 背景:右侧对话更早历史只靠常驻按钮「显示更早的对话」拉,且没有任何加载反馈(`historyLoadingRef` 是 ref,渲染不出来);用户滚上去之后没有「回到最新」的入口;展开「执行过程」/工具组/思考块时浏览器保持 `scrollTop`,新展开的正文长在视口下方,在底部展开更是直接顶出可视区。
|
||||
- 决策:删按钮改自动加载——触顶 24px 与「首帧后内容填不满视口」共用一道门(有更早历史 ∧ 不在加载中 ∧ 无失败记录),失败挂起、只留内联「加载更早对话失败 · 重试」且不自动重试;加载行挂载在列表最上方、延迟 150ms 才显示。前插按「回合 key(`data-turn-key`)+ 块内序号 + 相对列表顶边偏移」冻结锚点,列表显式 `overflow-anchor: none`;加载期间所有补偿还原同一个冻结锚点,加载结束后的下一次补偿再刷新。底部居中 `sticky` 胶囊「回到底部」(不跟随时来了新终态内容改「有新回复 · 回到底部」):距底 48px 阈值、点击平滑滚动并恢复跟随。`ResizeObserver` 观察列表直接子元素(`MutationObserver` 负责子元素变化时重订阅):跟随时任何高度变化贴底,否则冻结刚展开的折叠头,头部锚不住再对齐展开正文顶边;新一回合开始(`turnInFlight` 假转真)强制恢复跟随。滚动所有权(列表 ref、跟随最新、补偿、胶囊显隐)从 `DirectProjectChatView` 搬进 `DirectProjectConversation` 及其同目录 hook,控制器新增可渲染的 `historyLoading` / `historyError` 与 `retryEarlierHistory`。
|
||||
- 边界:只做 DirectProject;`PlanningChatView` 与 `App.tsx` 里的 `message-history-more` 遗留路径不动,因此该样式保留(后续项已写进 ADR)。新增元素全部用内联 Tailwind,`styles.css` 未改;阈值、文案与判据集中在同目录 `conversationScrollPolicy.ts`,契约见 [`【ADR】DirectProject对话滚动与历史自动加载-2026-10-02`](../../adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md),旧「单一事实源」ADR 的分页条目已改为引用它。
|
||||
- 验证:新增 37 条与实现同目录的用例(阈值与加载门、锚点读取/还原、折叠头冻结与正文对齐、hook 的触顶/填充/胶囊/未读/延迟加载行、组件层的按钮移除与内联重试行)由根 `vitest.config.ts` 的 include 收进门禁;`npm run ai-game-creator-shell:typecheck`、`eslint`(含新文件)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过;滚动观感(顶部加载圈、胶囊显隐、底部展开回贴、历史前插不跳)留真机手动验收。
|
||||
- 追加(2026-10-02,两个真机缺陷的根因与修正):①「点回到底部只下去一屏、到不了底」——平滑动画期间滚动事件把「跟随最新」翻成假,布局补偿与锚点还原接着写 `scrollTop`,而真实浏览器里任何一次写都会取消正在跑的平滑动画;现在滚动位置由这次程序化滚动独占(滚动事件只在贴底时才结算并交还,补偿整段跳过),滚轮 / 触摸 / 键盘接手立刻交还,避免标记永远挂着。②「展开折叠块没有自动滚动到位」——旧规则只在展开正文比视口还高时才动,正文矮的折叠块(工具组、思考块)展开后正文仍在视口外;现在冻结折叠头之后统一补「刚好露出新展开正文」的最小位移(底边超出就补超出量,比视口还高则对齐正文顶边),两段位移合成一次写。落在 `conversationToggleReveal.ts`(新增纯函数 `toggleRevealDelta`)与 `useConversationScroll.ts`(新增 `programmaticScrollRef`)。
|
||||
- 验证(2026-10-02 追加):同目录用例补齐到 48 条(新增 `toggleRevealDelta` 六例、hook 的四例程序化滚动用例,其中两例在修正前确实红)全部通过;Chromium 真机脚本复验三处——点回到底部收敛到 `scrollHeight - clientHeight`、底部展开贴到新底、视口外展开补 269px 后正文底边正好贴视口下缘。
|
||||
- 追加(2026-10-02,换会话复位滚动所有权):`useConversationScroll` 的跟随最新 / `atBottom` / `hasNewReply` / 前插锚点 / 折叠头 / 程序化滚动标记只在挂载时初始化一次,而 `DirectProjectChatView` 切项目时不重挂载——在项目 A 往上滚过再切 B,B 首屏不贴底且胶囊直接显示「有新回复 · 回到底部」。现在把会话身份(`conversationKey`,DirectProject 传项目路径)作为显式信号传进 hook,身份变化即复位这批状态、同步终态指纹并重新贴底;不改成 `key` 重建列表,避免消息列表重建与加载行 150ms 延迟计时重来,同一个项目重开也不算换会话。
|
||||
- 验证(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-02,review:动画期间内容变高会把程序化滚动标记卡住):`scrollToBottom` 把点击那一刻的 `scrollHeight` 当动画目标,而流式正文 / 图片撑开会让它在动画期间继续变高;`programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是动画停在旧目标后标记永远为真——布局补偿整段被跳过(列表不再跟随新内容),胶囊又已按「已贴底」隐掉,用户停在底部之上却没有任何指示和自动跟随。修正:程序化滚动期间布局补偿不写 `scrollTop`,但内容变高时把动画目标重新对准新的底部(`scrollListToBottom(list, 'smooth')`),动画继续跑到真正的底,标记照常在贴底时交还。
|
||||
- 验证(2026-10-02 追加,动画目标重对准):`useConversationScroll.test.tsx` 新增一例——点击回到底部后内容变高(`scrollHeight` 1200→1500)触发一次布局变化,断言动画目标从 `[1200]` 变为 `[1200, 1500]`、落到新底部后仍能交出控制权;修正前在目标数组处红。
|
||||
- 追加(2026-10-02,review:错误行的重试按钮在断言式 live region 内):`DirectProjectHistoryErrorRow` 原来把重试按钮渲染在 `<p role="alert">` 里面,而 `role="alert"` 隐含 `aria-live="assertive"` + `aria-atomic="true"`,交互控件会被卷进整段断言性播报、读屏也不一定把它当可聚焦按钮。修正:容器改为普通 `<div>`,`role="alert"` 只包住「加载更早对话失败」文案本身,重试按钮是 live region 之外的兄弟。
|
||||
- 验证(2026-10-02 追加,错误行结构):`DirectProjectConversation.test.tsx` 新增一例断言 `role="alert"` 节点不包含重试按钮、且文案仍在 alert 内并仍可点击;修正前红。
|
||||
- 追加(2026-10-02,review:世代号推进时机):`historyLoadTokenRef` 的换项目推进原来只在复位 effect 里,而 `projectPathRef.current` 是渲染期赋值的——交接窗口里守卫不再即时生效:React 的 passive effect 走宏任务、promise 续体走微任务,切换提交之后、复位 effect 之前落地的旧读取拿到的仍是旧世代号,会照常合并条目、写游标、关加载态(复位 effect 随后清掉状态,故终态没坏,但白跑一次跨项目读取且留下瞬时脏状态)。修正:推进移到渲染期,与 `projectPathRef` 同一处、只在路径真的变化时推进;复位 effect 不再推进。安全性依据:同一提交里子组件「填充视口」effect 的判据(`turns` / `historyHasMore` / `historyLoading` / `historyError` / 稳定的 `loadEarlier`)都不变,订阅状态复位本身也是 effect,因此这个窗口里不可能新起一次带旧游标的读取,去掉 effect 里的那一次推进不会放过它。
|
||||
- 验证(2026-10-02 追加,世代号推进时机):该窗口依赖 React 调度(act 会把 effect 与断言放在同一个作用域里冲掉),jsdom 下无法构造出「旧读取先于复位 effect 落地」的确定性用例,因此没有新增红灯用例;既有的控制器世代用例(切项目、A→B→A)保持全绿,行为等价性由「本窗口内不会有新读取启动」的依赖分析支撑。真机若要硬证据,需在慢读取期间切项目并观察是否多打一次跨项目读取。
|
||||
|
||||
## 2026-10-01 Web、后台与 AGC 一键联调
|
||||
|
||||
- 背景:Web、管理后台和 AGC 同时开发时,分别启动入口容易产生两套 API/worker/SpacetimeDB,以及重复后台 Vite。
|
||||
|
||||
@@ -98,7 +98,7 @@ readHistory(threadId, { beforeItemId?, limit }) -> {
|
||||
}
|
||||
```
|
||||
|
||||
`subscribe` 不返回完整历史。`lastCompletedItemId` 只是历史读取锚点,前端自行按 item ID 懒加载需要的历史切片。`events` 是当前运行态重建所需的未完成 item 原始事件,以及当前 turn 的生命周期锚点;前端用同一个 reducer 重放 bootstrap 和后续事件。Rust 不保存或理解前端 reducer state。只要 DirectProject 历史切片返回 `hasMore`,聊天视图必须显示“显示更早的对话”入口,并允许按钮或滚动触发下一页,即使当前可见消息窗口没有隐藏消息。
|
||||
`subscribe` 不返回完整历史。`lastCompletedItemId` 只是历史读取锚点,前端自行按 item ID 懒加载需要的历史切片。`events` 是当前运行态重建所需的未完成 item 原始事件,以及当前 turn 的生命周期锚点;前端用同一个 reducer 重放 bootstrap 和后续事件。Rust 不保存或理解前端 reducer state。只要 DirectProject 历史切片返回 `hasMore`,聊天视图就必须能继续往前取页,即使当前可见消息窗口没有隐藏消息;入口形态(触顶自动加载 + 填充视口补载、加载行与失败重试、前插不跳)由 [`【ADR】DirectProject对话滚动与历史自动加载-2026-10-02`](../adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md) 规定,已不再有常驻按钮。
|
||||
|
||||
`consume` 不接收或返回 cursor。每个 subscriber 在 Rust 内部持有自己的 cursor,并在加锁的临界区内完成过期判断、读取和 cursor 前进。前端只持有 `subscriptionId` 与 reducer state。并发 `consume` 不重复返回同一批事件。
|
||||
|
||||
@@ -106,7 +106,7 @@ readHistory(threadId, { beforeItemId?, limit }) -> {
|
||||
|
||||
`subscribe` 在同一个边界内先把新 subscriber 的游标钉在当时的队尾,再收集 bootstrap 的运行态事件,因此 bootstrap 返回的那批事件**就是**该 subscriber 此刻应处理的事件:前端直接 reduce 它们即可,不存在"先补一次 `consume` 才能拿到已暂存事件"的步骤。第一个例外只有回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,而前端要等回执到达才知道自己的 `subscriptionId`,这段窗口内的通知拿不到订阅身份。前端因此必须记一笔欠账,回执到达后立刻补一次 `consume` 取回那批事件;否则事件会卡在队列里等下一次通知,而一次回合的最后一个事件之后可能再也没有通知。除此之外不轮询,也不设任何定时 `consume`——唤醒只由 `notify` 负责。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。
|
||||
|
||||
首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。
|
||||
首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动继续向前分页(入口形态见上条 ADR)。系统不提供返回整份对话历史的命令。
|
||||
|
||||
### 事件和顺序
|
||||
|
||||
|
||||
Reference in New Issue
Block a user