修复连续翻页时历史加载行忽隐忽现

- 加载行卸载加入 300ms 隐藏滞回:填充视口的自动加载连续翻页,两次之间只有一次 effect 回流,立即卸载会把加载行一帧内拆了又挂
- conversationScrollPolicy.ts 新增 HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS,与显示延迟 150ms 配对
- useDelayedFlag 增加 hideDelayMs 参数,值转假后延迟再隐藏,转真仍立即显示
- 补回归测试:连续翻页之间小于隐藏延迟的空隙不卸载加载行,真正结束后照常卸载
- ADR 第 1 节记录隐藏滞回的动机与阈值
This commit is contained in:
2026-10-07 10:49:41 +08:00
parent a8ae817205
commit 89d0f507e6
4 changed files with 63 additions and 10 deletions
@@ -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 = '重试';
@@ -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(<Harness {...props} historyLoading />);
act(() => {
vi.advanceTimersByTime(150);
});
expect(view.queryByTestId('loading')).not.toBeNull();
// 第一页落地:加载态转假,但填充视口的判据下一帧又会起一次,两次之间只隔一次 effect 回流。
view.rerender(<Harness {...props} historyLoading={false} />);
act(() => {
vi.advanceTimersByTime(50);
});
view.rerender(<Harness {...props} historyLoading />);
// 小于隐藏延迟的空隙不卸载:加载行不会在两次翻页之间消失重现。
expect(view.queryByTestId('loading')).not.toBeNull();
// 真正结束(转假并超过隐藏延迟)才卸载。
view.rerender(<Harness {...props} historyLoading={false} />);
act(() => {
vi.advanceTimersByTime(301);
});
expect(view.queryByTestId('loading')).toBeNull();
} finally {
vi.useRealTimers();
}
});
});
describe('点「回到底部」后的程序化滚动', () => {
@@ -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;
}
@@ -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 回流;立即卸载会把加载行一帧内拆了又挂。全是工具调用时更明显——工具组折在收起的 `<details>` 里,每页几乎不增加可见高度,视口一直填不满,加载行就在「页与页之间」忽隐忽现。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 端到端。