From c68531b0aab350818aab7f77c265ef50383c9654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 17:27:49 +0800 Subject: [PATCH 01/25] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20DirectProject=20=E5=AF=B9=E8=AF=9D=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E4=B8=8E=E5=8E=86=E5=8F=B2=E8=87=AA=E5=8A=A8=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=20ADR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ADR,记录更早历史自动加载、内联加载/错误行、回合 key 锚点前插、回到底部胶囊与折叠展开的滚动契约。 在旧「对话历史单一事实源」ADR 的分页条目上补充指向新 ADR 的引用,保留原分页锚点口径。 docs/README.md 登记新 ADR。 Co-authored-by: Junie --- docs/README.md | 1 + ...irectProject对话历史单一事实源-2026-09-16.md | 2 +- ...tProject对话滚动与历史自动加载-2026-10-02.md | 68 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md diff --git a/docs/README.md b/docs/README.md index f684f47e7..7b6640e3e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,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 拆分、命名、启用和启动时注入边界。 diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 8781afa25..38e9338fc 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -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` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 diff --git a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md new file mode 100644 index 000000000..a345cf1eb --- /dev/null +++ b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md @@ -0,0 +1,68 @@ +# 【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. 展开一折叠块(`
` 或 `[aria-expanded]`)时浏览器保持 `scrollTop` 不变,新展开的正文长在视口下方,用户必须再滚一次才能看到;反过来在底部展开时内容直接顶出可视区。 + +同一目录下的 `PlanningChatView` 复用 `.project-chat-message-list` / `.project-chat-conversation` 两个类名,所以任何滚动容器结构改动都必须留在 DirectProject 本地,不能改公共类。 + +## 决策 + +### 1. 更早历史:删掉按钮,自动加载 + 内联加载行 + +- 删除「显示更早的对话」按钮、`message-history-more` 样式与 `DirectProjectConversation` 里的条件渲染。 +- 触发一(滚动):`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"` 的「加载更早对话失败 · 重试」,重试是可聚焦按钮;**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。 + +### 2. 前插锚定:回合 key + 偏移,关掉浏览器原生锚定 + +- 锚点 = 顶部可见回合的 `data-turn-key` + 该回合内的块序号 + 相对列表顶边的像素偏移。 +- 合并更早历史前记录锚点,合并后按同一 key/序号把偏移还原,较早内容出现在上方而用户正在读的位置不动。 +- 列表上加 `overflow-anchor: none`,关掉浏览器原生 scroll anchoring,保证只有一套补偿在跑(不做「两套补偿交替生效」)。 +- 一次加载期间(`historyLoading` 为真)所有补偿——ResizeObserver 回调、加载行挂载、历史合并——都还原同一个冻结锚点;加载结束再刷新锚点。 + +### 3. 回到底部胶囊 + +- 列表底部居中的悬浮胶囊(`sticky`),跟着滚动容器走、不随内容滚走。 +- 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。 +- 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。 + +### 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` 工具类。 + +## 备选方案与取舍 + +1. **保留按钮 + 只加自动加载**:加载中仍靠按钮做唯一反馈,且删掉按钮后失败路径没有补救入口;按钮本身与滚动自动加载重复。 +2. **在列表外面套一层 viewport 做浮层定位**:`PlanningChatView` 共用同一套容器规则,且工作台里 `.project-chat-conversation` 是 `display: block` + `height: 100%` 几何,套一层就会让 `height: 100%` 的列表塌成内容高度;改公共类会连带策划对话。改用列表内的 `sticky` 胶囊,零结构改动。 +3. **只依赖原生 CSS scroll anchoring**:前插能免费对齐,但做不到「跟随时展开要贴底」,也无法在加载期间冻结同一套锚点;因此显式补偿 + 关闭原生锚定。 +4. **展开后总是把正文滚进视口**:会打断正在读历史的用户;采用「跟随时贴底 / 否则保锚点、头部锚不住才对齐正文顶部」。 +5. **自动重试失败的历史加载**:弱网下会反复打接口,且用户看不出到底在重试还是在挂起;改为挂起 + 内联重试行。 + +## 影响 + +- 历史加载失败不再只写顶部状态行,而是落到列表里的内联错误行;顶部状态行仍保留首屏读取失败等其它用途。 +- 滚动是表现层行为,正式状态仍在后端投影与运行态事件;本 ADR 不新增领域概念。 +- 阈值(触顶 24px、贴底 48px、spinner 150ms)是可按手感调整的常量,集中放在 `components/DirectProjectConversation/conversationScrollPolicy.ts`。 +- 验收:纯函数与 jsdom 组件测试覆盖阈值、锚点还原、加载/错误行、胶囊文案与显隐;滚动观感(顶部加载圈、胶囊出现与消失、底部展开回贴、历史前插不跳)必须真机手动验收——jsdom 没有布局。 +- 明确的后续项(不在本次范围):`PlanningChatView` 与 `App.tsx` 遗留 `message-history-more` 路径的同款改造、未读条数徽标、Playwright 端到端。 From f0ae0bd5d7af8bfde3b7e2ae40f8eca3ff063028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 17:41:55 +0800 Subject: [PATCH 02/25] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=20DirectProject=20=E5=AF=B9=E8=AF=9D=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=88=A4=E6=8D=AE=E3=80=81=E9=94=9A=E7=82=B9=E4=B8=8E?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E8=A1=A5=E5=81=BF=E7=BA=AF=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 conversationScrollPolicy.ts:滚动/自动加载阈值、加载门、胶囊文案与填充视口判据集中在一处。 新增 conversationScrollAnchor.ts:回合 key + 块序号 + 偏移的锚点读取与还原,供前插补偿使用。 新增 conversationToggleReveal.ts:折叠头解析、头部冻结与超长正文对齐,覆盖 details 与 aria-expanded 两种折叠。 新增 useDelayedFlag.ts:延迟显示开关位,避免本地读取瞬间返回时闪一下加载行。 Co-authored-by: Junie --- .../conversationScrollAnchor.ts | 92 ++++++++++++++ .../conversationScrollPolicy.ts | 114 ++++++++++++++++++ .../conversationToggleReveal.ts | 89 ++++++++++++++ .../useDelayedFlag.ts | 22 ++++ 4 files changed, 317 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts new file mode 100644 index 000000000..077b41d31 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts @@ -0,0 +1,92 @@ +/** + * 更早历史前插时的视口锚点:读取与还原。 + * + * 锚点粒度是「回合 key(`data-turn-key`)+ 该回合内的块序号 + 相对列表顶边的像素偏移」: + * 合并更早历史只会把新回合插在更早的位置,已有回合的 key、块序号和块内结构都不变,所以这个 + * 锚点跨前插稳定。还原只改 `scrollTop`,不做任何 DOM 变更。 + * + * 为什么不用浏览器原生 scroll anchoring:原生补偿只能让"当前可见内容"不动,做不到「跟随时 + * 展开要贴底」,两套补偿同时生效还会互相抵消;列表因此显式关闭原生锚定,补偿只走这一份。 + */ + +export type ConversationTurnAnchor = { + /** 回合身份:`DirectChatTurn.key`。 */ + key: string; + /** 同一个回合里的第几个块(`data-turn-key` 相同的节点按文档顺序编号)。 */ + index: number; + /** 该块顶边相对列表顶边的偏移,可为负。 */ + offset: number; +}; + +const TURN_BLOCK_SELECTOR = '[data-turn-key]'; + +function turnKeyOf(element: Element): string | null { + return element instanceof HTMLElement + ? (element.dataset.turnKey ?? null) + : null; +} + +/** 列表里所有带回合身份的块,按文档顺序。 */ +export function collectTurnBlocks(list: HTMLElement): HTMLElement[] { + return Array.from(list.querySelectorAll(TURN_BLOCK_SELECTOR)); +} + +/** 按「回合 key + 块序号」定位具体块;找不到返回 null。 */ +export function findTurnBlock( + list: HTMLElement, + key: string, + index: number, +): HTMLElement | null { + const blocks = collectTurnBlocks(list).filter( + (block) => turnKeyOf(block) === key, + ); + return blocks[index] ?? null; +} + +/** + * 顶部第一条真正露出视口的块。 + * + * 用 `bottom > 列表顶边` 而不是 `top >= 列表顶边`:滚到中间时最上面那条通常只露出一半, + * 它才是"用户正在读的位置",拿下一整条当锚点会在前插时把半条消息顶出去。 + */ +export function readTopVisibleTurnAnchor( + list: HTMLElement, +): ConversationTurnAnchor | null { + const listTop = list.getBoundingClientRect().top; + const blocks = collectTurnBlocks(list); + + for (let position = 0; position < blocks.length; position += 1) { + const block = blocks[position]!; + if (block.getBoundingClientRect().bottom - listTop <= 0) continue; + + const key = turnKeyOf(block); + if (!key) continue; + + let index = 0; + for (let earlier = 0; earlier < position; earlier += 1) { + if (turnKeyOf(blocks[earlier]!) === key) index += 1; + } + + return { key, index, offset: block.getBoundingClientRect().top - listTop }; + } + + return null; +} + +/** + * 按锚点还原 `scrollTop`,让同一个块回到原来的偏移。 + * + * 返回是否还原成功:锚点对应的块已经不在列表里时返回 false,调用方可以据此放弃这次补偿, + * 而不是拿着过期偏移去改 `scrollTop`。 + */ +export function restoreTurnAnchor( + list: HTMLElement, + anchor: ConversationTurnAnchor, +): boolean { + const block = findTurnBlock(list, anchor.key, anchor.index); + if (!block) return false; + + const listTop = list.getBoundingClientRect().top; + list.scrollTop += block.getBoundingClientRect().top - listTop - anchor.offset; + return true; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts new file mode 100644 index 000000000..845aec2c7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts @@ -0,0 +1,114 @@ +/** + * DirectProject 会话滚动与更早历史自动加载的判据(常量 + 纯函数)。 + * + * 这一层只有数字与文案,不碰 DOM:阈值、触发条件、胶囊文案都在这里定死, + * `useConversationScroll` 只负责把真实几何喂进来。契约与取舍见 + * `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`。 + */ + +/** + * 距列表顶边多少像素以内算「触顶」,触发更早历史加载。 + * + * 必须小于 {@link DIRECT_SCROLL_BOTTOM_THRESHOLD}:两个阈值如果相等,刚往下滚一点就同时 + * 被判成「触顶」和「在底部」,历史加载与跟随最新会互相打架。 + */ +export const DIRECT_HISTORY_TOP_THRESHOLD = 24; + +/** 距底边多少像素以内算「在底部」:决定是否跟随最新,以及回到底部胶囊的显隐。 */ +export const DIRECT_SCROLL_BOTTOM_THRESHOLD = 48; + +/** 加载行延迟多少毫秒才显示:本地读取常常瞬间返回,立即显示会闪一下。 */ +export const HISTORY_LOADING_INDICATOR_DELAY_MS = 150; + +export const HISTORY_LOADING_TEXT = '正在加载更早的对话'; +export const HISTORY_ERROR_TEXT = '加载更早对话失败'; +export const HISTORY_RETRY_TEXT = '重试'; +export const SCROLL_TO_BOTTOM_TEXT = '回到底部'; +export const SCROLL_TO_BOTTOM_UNREAD_TEXT = '有新回复 · 回到底部'; + +/** 滚动容器当前几何;三个值都取真实布局值,不做任何补偿。 */ +export type ConversationListMetrics = { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}; + +export function readConversationListMetrics(element: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}): ConversationListMetrics { + return { + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight, + }; +} + +/** + * 距底部还有多少像素。 + * + * 内容比视口短时按 0 处理:此时「距底部」是负数,直接比较会让 `isNearBottom` 恒真以外的 + * 阈值判断出现反直觉结果。 + */ +export function distanceFromBottom(metrics: ConversationListMetrics): number { + return Math.max( + 0, + metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight, + ); +} + +export function isNearBottom(metrics: ConversationListMetrics): boolean { + return distanceFromBottom(metrics) <= DIRECT_SCROLL_BOTTOM_THRESHOLD; +} + +export function isNearHistoryTop(metrics: ConversationListMetrics): boolean { + return metrics.scrollTop <= DIRECT_HISTORY_TOP_THRESHOLD; +} + +/** + * 内容还没填满视口。 + * + * 「显示更早的对话」按钮删除后,短历史只能靠这条判据继续补:只要还有 `hasMore`,就一直加载到 + * 填满视口或加载器说没有更早历史为止,否则用户看到的就是一块拉不动的静止画面。 + */ +export function needsViewportFill(metrics: ConversationListMetrics): boolean { + return metrics.scrollHeight <= metrics.clientHeight; +} + +/** 自动加载更早历史的前置门:三条都成立才允许发起。 */ +export type ConversationHistoryLoadGate = { + historyHasMore: boolean; + historyLoading: boolean; + historyError: string | null; +}; + +function canStartHistoryLoad(gate: ConversationHistoryLoadGate): boolean { + return gate.historyHasMore && !gate.historyLoading && !gate.historyError; +} + +/** + * 滚动触顶时的自动加载判据。 + * + * 失败后挂起:`historyError` 非空期间不再自动触发,只有手动重试(清掉错误)或切换项目才会 + * 重新开始——弱网下反复重试既看不到反馈,也会持续打接口。 + */ +export function shouldLoadEarlierOnScroll( + metrics: ConversationListMetrics, + gate: ConversationHistoryLoadGate, +): boolean { + return canStartHistoryLoad(gate) && isNearHistoryTop(metrics); +} + +/** 首帧之后填充视口的自动加载判据,与滚动触发共用同一道门。 */ +export function shouldFillViewport( + metrics: ConversationListMetrics, + gate: ConversationHistoryLoadGate, +): boolean { + return canStartHistoryLoad(gate) && needsViewportFill(metrics); +} + +/** 回到底部胶囊的文案:不跟随时又来了新内容才提示「有新回复」。 */ +export function scrollToBottomLabel(hasNewReply: boolean): string { + return hasNewReply ? SCROLL_TO_BOTTOM_UNREAD_TEXT : SCROLL_TO_BOTTOM_TEXT; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts new file mode 100644 index 000000000..1956fbc94 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts @@ -0,0 +1,89 @@ +/** + * 折叠块展开 / 收起后的视口处理。 + * + * 展开一个 `
` 或 `[aria-expanded]` 块不会改变 `scrollTop`,但会把正文长在视口下方: + * 用户必须再滚一次才能看到刚展开的内容。这里的做法是: + * 1. 头部仍在视口里 → 把它拉回展开前的屏幕位置(折叠头冻结); + * 2. 展开正文比视口还高、头部已经锚不住 → 对齐正文顶边,让用户从正文开头读起。 + * + * 判断所需的"展开前位置"由调用方在点击捕获阶段记录,见 `useConversationScroll`。 + */ + +export type ConversationToggleFold = { + /** 触发折叠的头部元素:`
` 的 `summary`,或带 `aria-expanded` 的按钮。 */ + head: HTMLElement; + /** 头部在展开前相对视口顶边的位置(`getBoundingClientRect().top`)。 */ + headTop: number; +}; + +/** + * 从点击目标向上找折叠头。 + * + * 两种折叠头都要认:`DirectProjectTurn` 的「执行过程」是 `
`,`ToolCallGroup` 与 + * 「思考过程」是带 `aria-expanded` 的按钮。 + */ +export function resolveToggleHead(target: Element | null): HTMLElement | null { + if (!target) return null; + const summary = target.closest('summary'); + if (summary instanceof HTMLElement) return summary; + const expandable = target.closest('[aria-expanded]'); + if (expandable instanceof HTMLElement) return expandable; + return null; +} + +/** 折叠头对应的正文元素;解析不出来返回 null(此时只做头部冻结)。 */ +export function resolveToggleBody(head: HTMLElement): HTMLElement | null { + if (head.tagName === 'SUMMARY') { + const details = head.closest('details'); + if (!details) return null; + const body = Array.from(details.children).find( + (child) => child.tagName !== 'SUMMARY', + ); + return body instanceof HTMLElement ? body : null; + } + + const controls = head.getAttribute('aria-controls'); + if (!controls) return null; + return head.ownerDocument.getElementById(controls); +} + +/** + * 布局变化后把折叠头拉回展开前的位置;返回是否做了补偿。 + * + * 头部已经不在视口里时返回 false 并保持原状:继续按旧位置冻结会让画面跳到用户没在看的地方, + * 这时应该退回到「还原顶部可见回合锚点」。 + */ +export function freezeToggleHead( + list: HTMLElement, + fold: ConversationToggleFold, +): boolean { + if (!fold.head.isConnected) return false; + + const listRect = list.getBoundingClientRect(); + const headRect = fold.head.getBoundingClientRect(); + const headVisible = + headRect.bottom > listRect.top && headRect.top < listRect.bottom; + if (!headVisible) return false; + + list.scrollTop += headRect.top - fold.headTop; + revealExpandedBodyTop(list, fold.head); + return true; +} + +/** + * 展开正文高于视口且底部超出视口时,把正文顶边对齐到视口顶边。 + * + * 正文能在视口里放得下就不介入:那种情况下头部冻结已经足够,再动一次会把刚冻住的头部推走。 + */ +function revealExpandedBodyTop(list: HTMLElement, head: HTMLElement): void { + const body = resolveToggleBody(head); + if (!body) return; + + const listRect = list.getBoundingClientRect(); + const bodyRect = body.getBoundingClientRect(); + const viewportHeight = listRect.bottom - listRect.top; + if (bodyRect.height <= viewportHeight) return; + if (bodyRect.bottom <= listRect.bottom) return; + + list.scrollTop += bodyRect.top - listRect.top; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts new file mode 100644 index 000000000..41a5c6852 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +/** + * 延迟显示一个开关位:为真后等 `delayMs` 才转成可见,转假立即隐藏。 + * + * 用来避免「本地读取瞬间返回」时闪一下加载行:反馈要等得起,但不能一闪而过。 + */ +export function useDelayedFlag(value: boolean, delayMs: number): boolean { + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!value) { + setVisible(false); + return undefined; + } + + const timer = setTimeout(() => setVisible(true), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return visible; +} From 72ad9b8cdb7820f226a524d3fce921d06d9f49ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 17:42:14 +0800 Subject: [PATCH 03/25] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E8=81=8A=E5=A4=A9=E6=8E=A7=E5=88=B6=E5=99=A8=E6=9A=B4?= =?UTF-8?q?=E9=9C=B2=E6=9B=B4=E6=97=A9=E5=8E=86=E5=8F=B2=E7=9A=84=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=80=81=E3=80=81=E5=A4=B1=E8=B4=A5=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 控制器新增 historyLoading / historyError 两个可渲染状态,与并发判据用的 ref 同步镜像。 loadEarlierHistory 失败时写入 historyError 并挂起自动加载,成功路径清掉失败记录。 新增 retryEarlierHistory:先解挂起再走同一条加载入口,供内联错误行的重试按钮调用。 加载失败不再写顶部状态行(状态行留给首屏读取失败等其它用途)。 Co-authored-by: Junie --- .../useDirectProjectChatController.ts | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 8f97e9b3e..7f9bc87d5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -137,7 +137,8 @@ export type DirectProjectChatControllerProps = { * * 状态变量归属:reducer 的回合字段、待发消息投影、收口计数与 `history` / `live` 只由 * `directThreadChat.ts` 写;本文件的 `commandInFlight`(唯一入口 `beginCommandInFlight` / - * `endCommandInFlight`,只覆盖 IPC 在飞)、`localMessages`、埋点句柄与分页 ref 只服务发送与展示; + * `endCommandInFlight`,只覆盖 IPC 在飞)、`localMessages`、分页 ref 与更早历史的 + * `historyLoading` / `historyError`(渲染层读的加载行与内联错误行判据)只服务发送与展示; * 界面上的「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 @@ -182,8 +183,17 @@ export function useDirectProjectChatController({ */ const pendingTurns = directThread.state.pendingTurns; const [historyHasMore, setHistoryHasMore] = useState(false); - const historyOldestItemIdRef = useRef(null); + /** + * 更早历史的加载态与失败态:渲染层要拿它决定加载行与内联错误行的显隐。 + * + * `historyLoadingRef` 是同一件事的同步副本:并发判据必须在同一次事件处理里立刻生效, + * 而 `setState` 要等下一次渲染,直接用 state 判会放过第二次触发。 + */ + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); const historyLoadingRef = useRef(false); + const historyErrorRef = useRef(null); + const historyOldestItemIdRef = useRef(null); const projectPathRef = useRef(projectPath); projectPathRef.current = projectPath; const directTurnRunningRef = useRef(currentTurnRunning); @@ -196,6 +206,10 @@ export function useDirectProjectChatController({ setStatusNotice(''); setLocalMessages([]); setHistoryHasMore(false); + setHistoryLoading(false); + historyLoadingRef.current = false; + historyErrorRef.current = null; + setHistoryError(null); historyOldestItemIdRef.current = null; }, [projectPath]); @@ -649,18 +663,27 @@ export function useDirectProjectChatController({ } } + /** + * 自动加载更早历史(滚动触顶与填充视口共用这一条入口)。 + * + * 失败即挂起:`historyErrorRef` 非空期间这里直接返回,只有手动重试(先清掉错误)或切换 + * 项目才会重新开始。挂起态一定要能渲染出来——原来的常驻按钮删掉之后,内联错误行是用户 + * 唯一还能自己补救的入口。 + */ async function loadEarlierHistory() { const nextProjectPath = projectPath; if ( !enabled || !historyHasMore || !nextProjectPath || - historyLoadingRef.current + historyLoadingRef.current || + historyErrorRef.current ) return; const invoke = resolveTauriInvoke(); if (!invoke) return; historyLoadingRef.current = true; + setHistoryLoading(true); try { const pages = await readDirectHistoryPages({ existingEntries: directEntries, @@ -679,19 +702,27 @@ export function useDirectProjectChatController({ historyOldestItemIdRef.current = pages.firstItemId ?? historyOldestItemIdRef.current; if (pages.error) throw pages.error; + historyErrorRef.current = null; + setHistoryError(null); } catch (error) { // 与加载成功路径同样按项目守卫:切项目后在飞的读取失败不能算到新项目头上。 if (projectPathRef.current !== nextProjectPath) return; - setStatusNotice( - `读取更早的对话历史失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); + const message = error instanceof Error ? error.message : String(error); + historyErrorRef.current = message; + setHistoryError(message); } finally { historyLoadingRef.current = false; + setHistoryLoading(false); } } + /** 手动重试更早历史:先解挂起,再走同一条加载入口。 */ + function retryEarlierHistory() { + historyErrorRef.current = null; + setHistoryError(null); + void loadEarlierHistory(); + } + return { appendLocalMessage, attachmentNotice, @@ -702,8 +733,11 @@ export function useDirectProjectChatController({ directEntries, directTurnRunning: currentTurnRunning, directTurnStartedAt: currentTurnStartedAt, + historyError, historyHasMore, + historyLoading, loadEarlierHistory, + retryEarlierHistory, commandInFlight, localMessages, pendingTurns, From 1653a2be6eb517bcb85300dcf54319c98c47326a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 17:53:53 +0800 Subject: [PATCH 04/25] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E5=9B=9E=E5=90=88=E5=9D=97=E6=A0=87=E8=AE=B0=20data-turn-ke?= =?UTF-8?q?y=20=E4=BE=9B=E6=BB=9A=E5=8A=A8=E9=94=9A=E7=82=B9=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectProjectTurn 的正文块、执行过程折叠块与终态文案块写上 data-turn-key - ToolCallGroup 与 AgentReasoning 新增可选 turnKey 透传,工具组与思考块也带回合身份 - 锚点按「回合 key + 块内序号 + 偏移」定位,不新增 DOM 包裹层 Co-authored-by: Junie --- .../AgentReasoning/AgentReasoning.tsx | 7 +++++++ .../DirectProjectTurn.tsx | 21 ++++++++++++++++--- .../ToolCallGroup/ToolCallGroup.tsx | 7 +++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx index c3d7da397..99d32d69c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx @@ -18,10 +18,16 @@ function AgentReasoningImpl({ text, label = '思考过程', testId, + turnKey, }: { text: string; label?: string; testId?: string; + /** + * 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点按 + * 「回合 key + 块序号」定位这一块(见 `DirectProjectConversation/conversationScrollAnchor.ts`)。 + */ + turnKey?: string; }) { const [expanded, setExpanded] = useState(false); /* @@ -37,6 +43,7 @@ function AgentReasoningImpl({ className="design-agent-reasoning" aria-label={label} data-testid={testId} + data-turn-key={turnKey} onToggle={(event) => setExpanded((event.currentTarget as HTMLDetailsElement).open) } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx index 8ac6cb5f9..dce1a4c56 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx @@ -58,7 +58,11 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { ); if (turn.state !== 'finished') return blocks; return ( -
+
执行过程
{blocks}
@@ -78,6 +82,7 @@ function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { className="message-turn-usage" data-testid="turn-usage" data-turn-running="false" + data-turn-key={turn.key} > {`本轮结束于 ${new Date(endedAt).toLocaleTimeString('zh-CN', { hour12: false, @@ -99,17 +104,27 @@ function renderBlock( calls={block.calls} active={turn.state === 'running'} className="message-tool-call" + turnKey={turn.key} /> ); } if (block.kind === 'reasoning') { return ( - + ); } const role = block.kind === 'user' ? 'user' : 'assistant'; return ( -
+
Date: Fri, 2 Oct 2026 17:54:03 +0800 Subject: [PATCH 05/25] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E5=AF=B9=E8=AF=9D=E8=87=AA=E5=8A=A8=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=9B=B4=E6=97=A9=E5=8E=86=E5=8F=B2=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=9B=9E=E5=88=B0=E5=BA=95=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删掉常驻的「显示更早的对话」按钮,改由触顶与填充视口两条触发自动加载,失败即挂起并留内联重试行 - 新增 useConversationScroll:列表唯一滚动所有权(跟随最新、ResizeObserver 补偿、前插锚点冻结、胶囊显隐) - 新增顶部加载行与底部居中 sticky 胶囊两个表现组件,文案与阈值取自 conversationScrollPolicy - 列表追加 overflow-anchor:none 关掉原生锚定;「有新回复」按最后一轮终态内容指纹判定 - DirectProjectChatView 交出 messagesRef / shouldFollowLatestRef / handleScroll,只传加载状态与回调 Co-authored-by: Junie --- .../chat/DirectProjectChatView.tsx | 30 +- .../DirectProjectConversation.tsx | 66 +++-- .../DirectProjectHistoryRow.tsx | 54 ++++ .../DirectProjectScrollToBottomCapsule.tsx | 29 ++ .../conversationScrollPolicy.ts | 15 + .../useConversationScroll.ts | 269 ++++++++++++++++++ 6 files changed, 422 insertions(+), 41 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectHistoryRow.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index e10829c5f..b914c8db9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -1,15 +1,12 @@ -import type { UIEventHandler } from 'react'; import type { Ref } from 'react'; import { useCallback, useEffect, useImperativeHandle, useMemo, - useRef, useState, } from 'react'; -import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; @@ -106,8 +103,6 @@ export function DirectProjectChatView({ }: DirectProjectChatViewProps) { const { assets, projectId, refresh, versions } = useDirectProjectManifest(projectPath); - const messagesRef = useRef(null); - const shouldFollowLatestRef = useRef(true); const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); const [approvalOpen, setApprovalOpen] = useState(false); @@ -137,8 +132,11 @@ export function DirectProjectChatView({ directEntries, directTurnRunning, directTurnStartedAt, + historyError, historyHasMore, + historyLoading, loadEarlierHistory, + retryEarlierHistory, localMessages, pendingTurns, startInitialTurn, @@ -197,7 +195,6 @@ export function DirectProjectChatView({ initialTurn.content, directCodexConversationMessageId(clientTurnId, 'user'), ); - shouldFollowLatestRef.current = true; startInitialTurn({ clientTurnId, ...(initialTurn.creationType @@ -209,28 +206,12 @@ export function DirectProjectChatView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [turnStatus.displayBusy, initialTurn, projectId, projectPath]); - useEffect(() => { - if (!shouldFollowLatestRef.current) return; - const list = messagesRef.current; - if (list) list.scrollTop = list.scrollHeight; - }, [directEntries, localMessages]); - useImperativeHandle(ref, () => ({ announce: (text: string) => { appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() }); }, })); - const handleScroll: UIEventHandler = (event) => { - const list = event.currentTarget; - shouldFollowLatestRef.current = - list.scrollHeight - list.scrollTop - list.clientHeight <= - AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD; - if (historyHasMore && list.scrollTop <= 24) { - void loadEarlierHistory(); - } - }; - return (
void loadEarlierHistory()} - onScroll={handleScroll} + onRetryEarlierHistory={() => void retryEarlierHistory()} /> {pendingConfirmation && onConfirmConfirmation && diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx index b9595d9ee..4aa29a5c2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx @@ -1,28 +1,40 @@ -import type { RefObject, UIEventHandler } from 'react'; - import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent'; import { useLiveNow } from '../../../../../features/project-workspace/useLiveNow'; import type { DirectChatTurn } from '../../conversation/directTurnPresentation'; import { formatTurnDuration } from '../ToolCallGroup/toolCallGroupPresentation'; +import { + DirectProjectHistoryErrorRow, + DirectProjectHistoryLoadingRow, +} from './DirectProjectHistoryRow'; +import { DirectProjectScrollToBottomCapsule } from './DirectProjectScrollToBottomCapsule'; import { DirectProjectTurn } from './DirectProjectTurn'; +import { useConversationScroll } from './useConversationScroll'; /** - * 会话区:回合列表、更早历史入口和运行中过程卡。 + * 会话区:回合列表、更早历史的加载 / 失败行与运行中过程卡。 + * + * **滚动归这一层所有**:贴底跟随、更早历史的自动加载、前插锚点与回到底部胶囊都在 + * `useConversationScroll` 里,视图不再自己持 `messagesRef` / `shouldFollowLatestRef` / + * `handleScroll`——三套补偿写同一份 `scrollTop`,分开持有必然互相覆盖。 * * 回合来自 DirectProject 自己的投影;这里不读历史、不发回合,只把容器给的状态渲染出来。 */ export function DirectProjectConversation({ turns, - messagesRef, historyHasMore, + historyLoading, + historyError, turnInFlight, activeTurnStartedAt, onLoadEarlierHistory, - onScroll, + onRetryEarlierHistory, }: { turns: DirectChatTurn[]; - messagesRef: RefObject; historyHasMore: boolean; + /** 更早历史正在读:驱动顶部加载行(延迟 150ms 才挂载)。 */ + historyLoading: boolean; + /** 更早历史读取失败:非空即挂起自动加载,只留内联错误行的重试。 */ + historyError: string | null; /** * 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。 * @@ -33,28 +45,48 @@ export function DirectProjectConversation({ turnInFlight: boolean; activeTurnStartedAt: number; onLoadEarlierHistory: () => void; - onScroll: UIEventHandler; + onRetryEarlierHistory: () => void; }) { + const { + listRef, + onScroll, + onToggleCapture, + scrollToBottom, + showScrollToBottom, + scrollToBottomLabel, + showHistoryLoading, + } = useConversationScroll({ + turns, + historyHasMore, + historyLoading, + historyError, + turnInFlight, + onLoadEarlierHistory, + }); return ( <> + {/* `overflow-anchor: none`:关掉浏览器原生 scroll anchoring,前插与展开的补偿只走 + `useConversationScroll` 这一份,避免两套补偿在同一帧里互相抵消。 */}
- {historyHasMore ? ( - + {showHistoryLoading ? : null} + {historyError ? ( + ) : null} {turns.map((turn) => ( ))} + {showScrollToBottom ? ( + + ) : null}
{turnInFlight ? ( +