From 89d0f507e641768d221cd1cd0752506da9fb3ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 10:49:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9E=E7=BB=AD=E7=BF=BB?= =?UTF-8?q?=E9=A1=B5=E6=97=B6=E5=8E=86=E5=8F=B2=E5=8A=A0=E8=BD=BD=E8=A1=8C?= =?UTF-8?q?=E5=BF=BD=E9=9A=90=E5=BF=BD=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加载行卸载加入 300ms 隐藏滞回:填充视口的自动加载连续翻页,两次之间只有一次 effect 回流,立即卸载会把加载行一帧内拆了又挂 - conversationScrollPolicy.ts 新增 HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS,与显示延迟 150ms 配对 - useDelayedFlag 增加 hideDelayMs 参数,值转假后延迟再隐藏,转真仍立即显示 - 补回归测试:连续翻页之间小于隐藏延迟的空隙不卸载加载行,真正结束后照常卸载 - ADR 第 1 节记录隐藏滞回的动机与阈值 --- .../conversationScrollPolicy.ts | 9 +++++ .../useConversationScroll.test.tsx | 37 +++++++++++++++++++ .../useDelayedFlag.ts | 22 +++++++---- ...tProject对话滚动与历史自动加载-2026-10-02.md | 5 ++- 4 files changed, 63 insertions(+), 10 deletions(-) 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 index af8d578c6..b0dea574c 100644 --- 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 @@ -20,6 +20,15 @@ export const DIRECT_SCROLL_BOTTOM_THRESHOLD = 48; /** 加载行延迟多少毫秒才显示:本地读取常常瞬间返回,立即显示会闪一下。 */ export const HISTORY_LOADING_INDICATOR_DELAY_MS = 150; +/** + * 加载行显示后,加载态转假再等多少毫秒才卸载。 + * + * 填充视口的自动加载是**连续翻页**的:一次加载落地后下一帧就又起一次,两次之间只有一个 effect + * 回流。立即卸载会把加载行一帧内拆了又挂(表现为「正在加载更早的对话」忽隐忽现),这段隐藏延迟 + * 让连续加载之间的小空隙不触发卸载;真正的结束(超时后仍为假)照常卸载。 + */ +export const HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS = 300; + export const HISTORY_LOADING_TEXT = '正在加载更早的对话'; export const HISTORY_ERROR_TEXT = '加载更早对话失败'; export const HISTORY_RETRY_TEXT = '重试'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx index 620ec09f2..51e8a502b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx @@ -387,6 +387,43 @@ describe('加载行', () => { vi.useRealTimers(); } }); + + it('连续翻页之间的小空隙不卸载加载行:加载态不再忽隐忽现', () => { + vi.useFakeTimers(); + try { + stubListMetrics({ scrollTop: 0, scrollHeight: 1200, clientHeight: 400 }); + const props = { + turns: [] as DirectChatTurn[], + historyHasMore: true, + historyError: null, + onLoadEarlierHistory: () => undefined, + }; + const view = render(); + act(() => { + vi.advanceTimersByTime(150); + }); + expect(view.queryByTestId('loading')).not.toBeNull(); + + // 第一页落地:加载态转假,但填充视口的判据下一帧又会起一次,两次之间只隔一次 effect 回流。 + view.rerender(); + act(() => { + vi.advanceTimersByTime(50); + }); + view.rerender(); + + // 小于隐藏延迟的空隙不卸载:加载行不会在两次翻页之间消失重现。 + expect(view.queryByTestId('loading')).not.toBeNull(); + + // 真正结束(转假并超过隐藏延迟)才卸载。 + view.rerender(); + act(() => { + vi.advanceTimersByTime(301); + }); + expect(view.queryByTestId('loading')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); }); describe('点「回到底部」后的程序化滚动', () => { 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 index 41a5c6852..f504cf60a 100644 --- 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 @@ -1,22 +1,28 @@ import { useEffect, useState } from 'react'; /** - * 延迟显示一个开关位:为真后等 `delayMs` 才转成可见,转假立即隐藏。 + * 延迟显示一个开关位:为真后等 `delayMs` 才转成可见,转假后再等 `hideDelayMs` 才隐藏。 * - * 用来避免「本地读取瞬间返回」时闪一下加载行:反馈要等得起,但不能一闪而过。 + * `delayMs` 用来避免「本地读取瞬间返回」时闪一下加载行:反馈要等得起,但不能一闪而过。 + * `hideDelayMs` 是反向的滞回:连续加载之间只隔一次 effect 回流,立即隐藏会让加载行一帧内反复 + * 挂卸。默认 0 表示转假即隐藏,保持「只延迟显示」的原语义。 */ -export function useDelayedFlag(value: boolean, delayMs: number): boolean { +export function useDelayedFlag( + value: boolean, + delayMs: number, + hideDelayMs = 0, +): boolean { const [visible, setVisible] = useState(false); useEffect(() => { - if (!value) { - setVisible(false); - return undefined; + if (value) { + const timer = setTimeout(() => setVisible(true), delayMs); + return () => clearTimeout(timer); } - const timer = setTimeout(() => setVisible(true), delayMs); + const timer = setTimeout(() => setVisible(false), hideDelayMs); return () => clearTimeout(timer); - }, [value, delayMs]); + }, [value, delayMs, hideDelayMs]); return visible; } diff --git a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md index 79c98aa6f..22fb1dd55 100644 --- a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md +++ b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md @@ -20,7 +20,8 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme - 触发一(滚动):`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="status"`、`aria-live="polite"` 的「正在加载更早的对话」,带旋转圈;延迟 150ms 才显示,加载态转假后再留 300ms 才卸载(隐藏滞回)。它按需挂载,靠位置补偿(见第 2 条)保证下面的消息不跳。 + - 隐藏滞回是必须的:填充视口的自动加载是连续翻页的(一次加载落地后下一帧又起一次),两次之间只有一个 effect 回流;立即卸载会把加载行一帧内拆了又挂。全是工具调用时更明显——工具组折在收起的 `
` 里,每页几乎不增加可见高度,视口一直填不满,加载行就在「页与页之间」忽隐忽现。300ms 的隐藏延迟让连续加载之间的小空隙不卸载,真正结束(超时后仍为假)照常卸载。 - 失败:挂起自动加载,列表顶部保留一行内联错误行——`role="alert"` 只包住「加载更早对话失败」文案本身,重试是可聚焦按钮、留在 live region 之外(assertive + atomic 的 live region 里不放交互控件);**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。 - 一次加载与它所属的**世代**绑定:切换项目或新起一次读取都推进世代号(`historyLoadTokenRef`),旧世代落地时整段失效——不合并条目、不写游标、不关加载态。只比项目路径不够:A→B→A 之后在飞的旧读取又落回同一个路径,原守卫放行,会把新一代的加载行与 `historyLoadingRef` 这道并发闸门一起改掉。换项目的推进放在**渲染期**(与 `projectPathRef` 同一处),不放在复位 effect 里:passive effect 走宏任务、promise 续体走微任务,旧读取可能在「切换提交完成、复位 effect 还没跑」的窗口里落地,那时世代号还是旧的,守卫会放行。 @@ -80,6 +81,6 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme - 历史加载失败不再只写顶部状态行,而是落到列表里的内联错误行;顶部状态行仍保留首屏读取失败等其它用途。 - 滚动是表现层行为,正式状态仍在后端投影与运行态事件;本 ADR 不新增领域概念。 -- 阈值(触顶 24px、贴底 48px、spinner 150ms)是可按手感调整的常量,集中放在 `components/DirectProjectConversation/conversationScrollPolicy.ts`。 +- 阈值(触顶 24px、贴底 48px、spinner 显示 150ms / 隐藏滞回 300ms)是可按手感调整的常量,集中放在 `components/DirectProjectConversation/conversationScrollPolicy.ts`。 - 验收:纯函数与 jsdom 组件测试覆盖阈值、锚点还原(含收口后按块身份仍指向同一块、锚点块被折叠隐藏时放弃还原)、加载/错误行、胶囊文案与显隐;滚动观感(顶部加载圈、胶囊出现与消失、底部展开回贴、历史前插不跳、长回合收口时视口不跳、切项目后首屏贴底且不误报「有新回复」)必须真机手动验收——jsdom 没有布局。 - 明确的后续项(不在本次范围):`PlanningChatView` 与 `App.tsx` 遗留 `message-history-more` 路径的同款改造、未读条数徽标、Playwright 端到端。