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:
+7
-24
@@ -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<HTMLDivElement | null>(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<HTMLDivElement> = (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 (
|
||||
<section
|
||||
className="project-chat-surface is-direct-codex"
|
||||
@@ -244,13 +225,15 @@ export function DirectProjectChatView({
|
||||
onRequestGamePublish={onRequestGamePublish}
|
||||
/>
|
||||
<DirectProjectConversation
|
||||
conversationKey={projectPath ?? ''}
|
||||
turns={directTurns}
|
||||
messagesRef={messagesRef}
|
||||
historyHasMore={historyHasMore}
|
||||
historyLoading={historyLoading}
|
||||
historyError={historyError}
|
||||
turnInFlight={turnStatus.displayBusy}
|
||||
activeTurnStartedAt={activeTurnStartedAt}
|
||||
onLoadEarlierHistory={() => void loadEarlierHistory()}
|
||||
onScroll={handleScroll}
|
||||
onRetryEarlierHistory={() => void retryEarlierHistory()}
|
||||
/>
|
||||
{pendingConfirmation &&
|
||||
onConfirmConfirmation &&
|
||||
|
||||
+14
@@ -18,10 +18,22 @@ function AgentReasoningImpl({
|
||||
text,
|
||||
label = '思考过程',
|
||||
testId,
|
||||
turnKey,
|
||||
blockKey,
|
||||
}: {
|
||||
text: string;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
/**
|
||||
* 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点定位这一块
|
||||
* (见 `DirectProjectConversation/conversationScrollAnchor.ts`)。
|
||||
*/
|
||||
turnKey?: string;
|
||||
/**
|
||||
* 这一块的稳定块身份(`DirectChatBlock.key`):写进 `data-block-key`。锚点按「回合 key +
|
||||
* 块身份」定位,不按块序号——回合收口时过程块会被折进 `<details>`,序号会整体后移。
|
||||
*/
|
||||
blockKey?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
/*
|
||||
@@ -37,6 +49,8 @@ function AgentReasoningImpl({
|
||||
className="design-agent-reasoning"
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
data-turn-key={turnKey}
|
||||
data-block-key={blockKey}
|
||||
onToggle={(event) =>
|
||||
setExpanded((event.currentTarget as HTMLDetailsElement).open)
|
||||
}
|
||||
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DirectChatToolCard } from '../../conversation/directThreadChat';
|
||||
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
|
||||
import { DirectProjectConversation } from './DirectProjectConversation';
|
||||
|
||||
const turn = (key: string): DirectChatTurn => ({
|
||||
key,
|
||||
users: [{ kind: 'user', key: `${key}-u`, text: '做一个跳跃动作', at: 10 }],
|
||||
process: [],
|
||||
finals: [{ kind: 'assistant', key: `${key}-f`, text: '改好了', at: 20 }],
|
||||
state: 'finished',
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
});
|
||||
|
||||
/** 一个工具调用的持久化卡片:块身份来自条目 itemId,这里只要能渲染出来。 */
|
||||
const toolCall = (id: string): DirectChatToolCard => ({
|
||||
schemaVersion: 'agc-tool-call.v1',
|
||||
id,
|
||||
kind: 'command',
|
||||
title: '执行命令',
|
||||
summary: 'npm run build',
|
||||
status: 'completed',
|
||||
detail: { command: 'npm run build' },
|
||||
startedAt: 10,
|
||||
updatedAt: 20,
|
||||
});
|
||||
|
||||
/**
|
||||
* 带执行过程的回合:`renderTurnProcess` 对运行中的回合把过程块平铺、对已结束的回合把它们折进一个
|
||||
* `<details>`,所以「收口前后同一个块的块身份是否跟着块走」只能拿这种回合验。
|
||||
*/
|
||||
const processTurn = (
|
||||
state: DirectChatTurn['state'],
|
||||
overrides: Partial<DirectChatTurn> = {},
|
||||
): DirectChatTurn => ({
|
||||
key: 't1',
|
||||
users: [{ kind: 'user', key: 't1:u', text: '做一个跳跃动作', at: 10 }],
|
||||
process: [
|
||||
{ kind: 'tools', key: 't1:g1', calls: [toolCall('c1')] },
|
||||
{ kind: 'tools', key: 't1:g2', calls: [toolCall('c2')] },
|
||||
],
|
||||
finals: [{ kind: 'assistant', key: 't1:f', text: '改好了', at: 20 }],
|
||||
state,
|
||||
startedAt: 10,
|
||||
endedAt: state === 'finished' ? 20 : 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** 列表里所有块的块身份,按文档顺序;拿不到 `data-block-key` 的块是 null。 */
|
||||
function blockKeysOf(container: HTMLElement): (string | null)[] {
|
||||
return Array.from(container.querySelectorAll('[data-turn-key]')).map(
|
||||
(block) => block.getAttribute('data-block-key'),
|
||||
);
|
||||
}
|
||||
|
||||
function conversationElement(overrides: {
|
||||
conversationKey?: string;
|
||||
turns?: DirectChatTurn[];
|
||||
historyHasMore?: boolean;
|
||||
historyLoading?: boolean;
|
||||
historyError?: string | null;
|
||||
onLoadEarlierHistory?: () => void;
|
||||
onRetryEarlierHistory?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DirectProjectConversation
|
||||
conversationKey={overrides.conversationKey ?? '/projects/test'}
|
||||
turns={overrides.turns ?? []}
|
||||
historyHasMore={overrides.historyHasMore ?? false}
|
||||
historyLoading={overrides.historyLoading ?? false}
|
||||
historyError={overrides.historyError ?? null}
|
||||
turnInFlight={false}
|
||||
activeTurnStartedAt={0}
|
||||
onLoadEarlierHistory={overrides.onLoadEarlierHistory ?? (() => undefined)}
|
||||
onRetryEarlierHistory={
|
||||
overrides.onRetryEarlierHistory ?? (() => undefined)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderConversation(overrides: {
|
||||
conversationKey?: string;
|
||||
turns?: DirectChatTurn[];
|
||||
historyHasMore?: boolean;
|
||||
historyLoading?: boolean;
|
||||
historyError?: string | null;
|
||||
onLoadEarlierHistory?: () => void;
|
||||
onRetryEarlierHistory?: () => void;
|
||||
}) {
|
||||
return render(conversationElement(overrides));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('更早历史的入口', () => {
|
||||
it('常驻的「显示更早的对话」按钮已经删掉,改由滚动自动加载', () => {
|
||||
const view = renderConversation({ historyHasMore: true });
|
||||
expect(view.queryByText('显示更早的对话')).toBeNull();
|
||||
expect(view.container.querySelector('.message-history-more')).toBeNull();
|
||||
});
|
||||
|
||||
it('加载行延迟出现,文案是「正在加载更早的对话」', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const view = renderConversation({
|
||||
historyHasMore: true,
|
||||
historyLoading: true,
|
||||
});
|
||||
expect(view.queryByText('正在加载更早的对话')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
expect(view.getByText('正在加载更早的对话')).not.toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('失败挂起后列表顶部留一行内联错误,重试是唯一的手动出路', () => {
|
||||
const retry = vi.fn();
|
||||
const view = renderConversation({
|
||||
historyHasMore: true,
|
||||
historyError: '读取更早的对话历史失败',
|
||||
onRetryEarlierHistory: retry,
|
||||
});
|
||||
|
||||
expect(view.getByText('加载更早对话失败')).not.toBeNull();
|
||||
fireEvent.click(view.getByText('重试'));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('断言式 live region 里不放可交互控件:重试按钮是 alert 之外的兄弟', () => {
|
||||
const retry = vi.fn();
|
||||
const view = renderConversation({
|
||||
historyHasMore: true,
|
||||
historyError: '读取更早的对话历史失败',
|
||||
onRetryEarlierHistory: retry,
|
||||
});
|
||||
|
||||
// `role="alert"` 隐含 aria-live="assertive" + aria-atomic="true":整段会被当成一条断言性
|
||||
// 播报,交互控件嵌在里面既可能不被读屏当成可聚焦按钮,点击也落在 live region 内部。
|
||||
const alert = view.getByRole('alert');
|
||||
expect(alert.textContent).toContain('加载更早对话失败');
|
||||
expect(alert.contains(view.getByText('重试'))).toBe(false);
|
||||
|
||||
// 拆开之后仍在同一行里,重试仍是唯一的手动出路。
|
||||
fireEvent.click(view.getByText('重试'));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('前插锚点的标记', () => {
|
||||
it('每个块都带上所属回合的 data-turn-key', () => {
|
||||
const view = renderConversation({ turns: [turn('t1')] });
|
||||
expect(
|
||||
view.container.querySelectorAll('[data-turn-key="t1"]').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('每个可锚定的块都带上 data-block-key,且同一回合内唯一', () => {
|
||||
const view = renderConversation({ turns: [processTurn('finished')] });
|
||||
const keys = blockKeysOf(view.container);
|
||||
|
||||
// 正文块用 DirectChatBlock.key,过程包装块与终态文案各有自己的字面量块身份。
|
||||
expect(keys).toContain('t1:u');
|
||||
expect(keys).toContain('t1:g1');
|
||||
expect(keys).toContain('t1:g2');
|
||||
expect(keys).toContain('t1:f');
|
||||
expect(keys).toContain('process');
|
||||
expect(keys).toContain('usage');
|
||||
// 缺块身份的块会被锚点静默跳过:宁可这里红,也不要在滚动时才发现。
|
||||
expect(keys.every((key) => key !== null && key.length > 0)).toBe(true);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('回合收口后块身份跟着块走:块序号变了,块身份不变', () => {
|
||||
const view = renderConversation({ turns: [processTurn('running')] });
|
||||
// 运行中:过程块平铺,没有「执行过程」包装块。
|
||||
expect(blockKeysOf(view.container)).toEqual([
|
||||
't1:u',
|
||||
't1:g1',
|
||||
't1:g2',
|
||||
't1:f',
|
||||
]);
|
||||
|
||||
view.rerender(conversationElement({ turns: [processTurn('finished')] }));
|
||||
|
||||
// 收口后过程被折进一个 `<details>`,同一个工具组的序号从 1 变成 3——旧锚点存的是序号,
|
||||
// 会解析到隔壁块;块身份仍然只属于它自己。
|
||||
const keys = blockKeysOf(view.container);
|
||||
expect(keys).toEqual([
|
||||
't1:u',
|
||||
'process',
|
||||
't1:g1',
|
||||
't1:g2',
|
||||
't1:f',
|
||||
'usage',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('换会话的复位方式', () => {
|
||||
it('换项目不重建列表 DOM:走显式身份信号,而不是用 key 重建会话', () => {
|
||||
const view = renderConversation({ turns: [turn('t1')] });
|
||||
const listBefore = view.container.querySelector(
|
||||
'.project-chat-message-list',
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
conversationElement({
|
||||
conversationKey: '/projects/other',
|
||||
turns: [turn('t2')],
|
||||
}),
|
||||
);
|
||||
|
||||
// 复用同一个列表节点:加载行的 150ms 延迟计时与滚动所有权都由身份信号复位,
|
||||
// 不靠重建组件(重建会把列表和加载行一起推倒重来)。
|
||||
expect(view.container.querySelector('.project-chat-message-list')).toBe(
|
||||
listBefore,
|
||||
);
|
||||
});
|
||||
});
|
||||
+53
-17
@@ -1,28 +1,43 @@
|
||||
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({
|
||||
conversationKey,
|
||||
turns,
|
||||
messagesRef,
|
||||
historyHasMore,
|
||||
historyLoading,
|
||||
historyError,
|
||||
turnInFlight,
|
||||
activeTurnStartedAt,
|
||||
onLoadEarlierHistory,
|
||||
onScroll,
|
||||
onRetryEarlierHistory,
|
||||
}: {
|
||||
/** 会话身份键(项目路径):切项目即复位滚动所有权,不靠重建列表。 */
|
||||
conversationKey: string;
|
||||
turns: DirectChatTurn[];
|
||||
messagesRef: RefObject<HTMLDivElement | null>;
|
||||
historyHasMore: boolean;
|
||||
/** 更早历史正在读:驱动顶部加载行(延迟 150ms 才挂载)。 */
|
||||
historyLoading: boolean;
|
||||
/** 更早历史读取失败:非空即挂起自动加载,只留内联错误行的重试。 */
|
||||
historyError: string | null;
|
||||
/**
|
||||
* 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。
|
||||
*
|
||||
@@ -33,28 +48,49 @@ export function DirectProjectConversation({
|
||||
turnInFlight: boolean;
|
||||
activeTurnStartedAt: number;
|
||||
onLoadEarlierHistory: () => void;
|
||||
onScroll: UIEventHandler<HTMLDivElement>;
|
||||
onRetryEarlierHistory: () => void;
|
||||
}) {
|
||||
const {
|
||||
listRef,
|
||||
onScroll,
|
||||
onToggleCapture,
|
||||
scrollToBottom,
|
||||
showScrollToBottom,
|
||||
scrollToBottomLabel,
|
||||
showHistoryLoading,
|
||||
} = useConversationScroll({
|
||||
conversationKey,
|
||||
turns,
|
||||
historyHasMore,
|
||||
historyLoading,
|
||||
historyError,
|
||||
turnInFlight,
|
||||
onLoadEarlierHistory,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{/* `overflow-anchor: none`:关掉浏览器原生 scroll anchoring,前插与展开的补偿只走
|
||||
`useConversationScroll` 这一份,避免两套补偿在同一帧里互相抵消。 */}
|
||||
<div
|
||||
ref={messagesRef}
|
||||
className="message-list project-chat-message-list"
|
||||
ref={listRef}
|
||||
className="message-list project-chat-message-list [overflow-anchor:none]"
|
||||
aria-label="陶泥儿消息"
|
||||
onScroll={onScroll}
|
||||
onClickCapture={onToggleCapture}
|
||||
>
|
||||
{historyHasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
className="message-history-more"
|
||||
onClick={onLoadEarlierHistory}
|
||||
>
|
||||
显示更早的对话
|
||||
</button>
|
||||
{showHistoryLoading ? <DirectProjectHistoryLoadingRow /> : null}
|
||||
{historyError ? (
|
||||
<DirectProjectHistoryErrorRow onRetry={onRetryEarlierHistory} />
|
||||
) : null}
|
||||
{turns.map((turn) => (
|
||||
<DirectProjectTurn key={turn.key} turn={turn} />
|
||||
))}
|
||||
{showScrollToBottom ? (
|
||||
<DirectProjectScrollToBottomCapsule
|
||||
label={scrollToBottomLabel}
|
||||
onClick={scrollToBottom}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{turnInFlight ? (
|
||||
<AgentMessageContent
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
HISTORY_ERROR_TEXT,
|
||||
HISTORY_LOADING_TEXT,
|
||||
HISTORY_RETRY_TEXT,
|
||||
} from './conversationScrollPolicy';
|
||||
|
||||
/**
|
||||
* 列表顶部的两行历史状态条(加载中 / 加载失败)。
|
||||
*
|
||||
* 它们都排在**最旧一条回合之上**——内容将出现的位置,而不是浮在视口上——所以不影响下面消息的
|
||||
* 可读性;前插期间的 `scrollTop` 补偿由 `useConversationScroll` 负责,这两行本身只管一层皮。
|
||||
*
|
||||
* 按钮删掉之后,「加载更早对话失败」这行是用户唯一还能自己补救的入口:它一直留到重试成功,
|
||||
* 不自动消失。
|
||||
*/
|
||||
|
||||
/** 加载更早历史时的旋转圈。按需挂载,是否显示由 `useDelayedFlag` 决定。 */
|
||||
export function DirectProjectHistoryLoadingRow() {
|
||||
return (
|
||||
<p
|
||||
className="flex items-center justify-center gap-1.5 py-2 text-xs text-[color:var(--platform-text-muted)]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2 className="animate-spin" size={14} aria-hidden="true" />
|
||||
{HISTORY_LOADING_TEXT}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** 自动加载挂起后的内联错误行:重试是唯一的手动出路,成功前一直留在列表顶部。 */
|
||||
export function DirectProjectHistoryErrorRow({
|
||||
onRetry,
|
||||
}: {
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-2 text-xs text-[color:var(--platform-button-danger-text)]">
|
||||
{/* `role="alert"` 隐含 assertive + atomic:只有文案本身进 live region,重试按钮留在外面,
|
||||
否则整段会被当成一条断言性播报,可聚焦控件也被卷进 live region 内部。 */}
|
||||
<span role="alert">{HISTORY_ERROR_TEXT}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-[color:var(--platform-subpanel-border)] px-2 py-0.5 text-xs text-[color:var(--platform-text-strong)] transition hover:bg-[var(--platform-neutral-bg)]"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{HISTORY_RETRY_TEXT}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 底部居中的「回到底部」胶囊。
|
||||
*
|
||||
* 用 `sticky bottom-*` 而不是浮层:工作台里 `.project-chat-conversation` 是 `display: block`
|
||||
* 加 `height: 100%` 的几何,在列表外套一层定位容器会把列表的 `height: 100%` 塌成内容高度;
|
||||
* 粘在列表内部的胶囊零结构改动,两块宿主(工作台侧栏与独立页面)都能拿到。
|
||||
*
|
||||
* 显隐与文案由调用方决定(距底超过阈值才出现;不跟随时来了新回复就换文案),这里只负责表现。
|
||||
*/
|
||||
export function DirectProjectScrollToBottomCapsule({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="sticky bottom-3 z-10 mx-auto mt-1 flex w-fit items-center gap-1.5 rounded-full border border-[color:var(--platform-subpanel-border)] bg-[var(--platform-neutral-bg)] px-3 py-1.5 text-xs font-medium text-[color:var(--platform-text-strong)] shadow-[0_6px_18px_rgba(24,32,47,0.16)] backdrop-blur"
|
||||
onClick={onClick}
|
||||
>
|
||||
<ArrowDown size={13} aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+37
-4
@@ -30,6 +30,12 @@ import {
|
||||
* - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`:
|
||||
* 只有宿主开始事件到了才能这么说。
|
||||
*/
|
||||
/** 「执行过程」包装块的块身份:只在同一回合内要求唯一,与条目块的 `${回合 key}:${条目 id}` 不会撞。 */
|
||||
const PROCESS_BLOCK_KEY = 'process';
|
||||
|
||||
/** 终态文案的块身份:同上,同一个回合里只出现一次。 */
|
||||
const USAGE_BLOCK_KEY = 'usage';
|
||||
|
||||
export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) {
|
||||
const streamingKey =
|
||||
turn.state === 'running'
|
||||
@@ -50,7 +56,14 @@ export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 运行中的回合把执行过程平铺,已结束的回合折叠进「执行过程」。 */
|
||||
/**
|
||||
* 运行中的回合把执行过程平铺,已结束的回合折叠进「执行过程」。
|
||||
*
|
||||
* 两种结构下每个块都带 `data-block-key`:会话滚动的前插锚点按「回合 key + 块身份」定位,
|
||||
* 不按块序号——收口时过程块被折进 `<details>`,块序号会整体后移(见
|
||||
* `conversationScrollAnchor.ts` 与 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`)。
|
||||
* 「执行过程」包装块与终态文案没有 `DirectChatBlock`,各用一个回合内唯一的字面量块身份。
|
||||
*/
|
||||
function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) {
|
||||
if (turn.process.length === 0) return null;
|
||||
const blocks = turn.process.map((block) =>
|
||||
@@ -58,7 +71,12 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) {
|
||||
);
|
||||
if (turn.state !== 'finished') return blocks;
|
||||
return (
|
||||
<details className="message-turn-process" data-testid="turn-process">
|
||||
<details
|
||||
className="message-turn-process"
|
||||
data-testid="turn-process"
|
||||
data-turn-key={turn.key}
|
||||
data-block-key={PROCESS_BLOCK_KEY}
|
||||
>
|
||||
<summary>执行过程</summary>
|
||||
<div className="message-turn-process-body">{blocks}</div>
|
||||
</details>
|
||||
@@ -78,6 +96,8 @@ function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) {
|
||||
className="message-turn-usage"
|
||||
data-testid="turn-usage"
|
||||
data-turn-running="false"
|
||||
data-turn-key={turn.key}
|
||||
data-block-key={USAGE_BLOCK_KEY}
|
||||
>
|
||||
{`本轮结束于 ${new Date(endedAt).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
@@ -99,17 +119,30 @@ function renderBlock(
|
||||
calls={block.calls}
|
||||
active={turn.state === 'running'}
|
||||
className="message-tool-call"
|
||||
turnKey={turn.key}
|
||||
blockKey={block.key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'reasoning') {
|
||||
return (
|
||||
<AgentReasoning key={block.key} text={block.text} label="思考过程" />
|
||||
<AgentReasoning
|
||||
key={block.key}
|
||||
text={block.text}
|
||||
label="思考过程"
|
||||
turnKey={turn.key}
|
||||
blockKey={block.key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const role = block.kind === 'user' ? 'user' : 'assistant';
|
||||
return (
|
||||
<div key={block.key} className={`message message--${role}`}>
|
||||
<div
|
||||
key={block.key}
|
||||
className={`message message--${role}`}
|
||||
data-turn-key={turn.key}
|
||||
data-block-key={block.key}
|
||||
>
|
||||
<AgentMessageContent tone={tone}>
|
||||
<ChatMarkdownMessage
|
||||
role={role}
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
collectTurnBlocks,
|
||||
type ConversationTurnAnchor,
|
||||
findTurnBlock,
|
||||
invalidateTurnBlocks,
|
||||
readTopVisibleTurnAnchor,
|
||||
restoreTurnAnchor,
|
||||
} from './conversationScrollAnchor';
|
||||
|
||||
type StubRect = { top: number; bottom: number };
|
||||
|
||||
/** 一个锚点候选块:回合 key 与稳定块身份,对应 DOM 上的 `data-turn-key` / `data-block-key`。 */
|
||||
type BlockSpec = readonly [turnKey: string, blockKey: string];
|
||||
|
||||
/** jsdom 不做布局:每个块的屏幕位置都由用例显式给出。 */
|
||||
function stubRect(element: HTMLElement, rect: StubRect): void {
|
||||
element.getBoundingClientRect = () =>
|
||||
({
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
height: rect.bottom - rect.top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: rect.top,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
}
|
||||
|
||||
function buildList(
|
||||
specs: readonly BlockSpec[],
|
||||
listRect: StubRect = { top: 0, bottom: 400 },
|
||||
) {
|
||||
document.body.innerHTML = '';
|
||||
const list = document.createElement('div');
|
||||
for (const [turnKey, blockKey] of specs) {
|
||||
const block = document.createElement('div');
|
||||
block.dataset.turnKey = turnKey;
|
||||
block.dataset.blockKey = blockKey;
|
||||
list.appendChild(block);
|
||||
}
|
||||
document.body.appendChild(list);
|
||||
stubRect(list, listRect);
|
||||
return { list, blocks: Array.from(list.children) as HTMLElement[] };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('collectTurnBlocks', () => {
|
||||
it('按文档顺序收集所有可锚定的块', () => {
|
||||
const { list } = buildList([
|
||||
['t1', 'b1'],
|
||||
['t2', 'b2'],
|
||||
]);
|
||||
expect(collectTurnBlocks(list)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('只收集同时带回合 key 与块身份的块', () => {
|
||||
const { list } = buildList([['t1', 'b1']]);
|
||||
// 有回合 key、没有块身份:定位不到它,读锚点时也会被跳过,干脆不进候选集合。
|
||||
const keyOnly = document.createElement('div');
|
||||
keyOnly.dataset.turnKey = 't1';
|
||||
list.appendChild(keyOnly);
|
||||
invalidateTurnBlocks(list);
|
||||
|
||||
expect(collectTurnBlocks(list)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('同一列表连续收集只查一次 DOM', () => {
|
||||
const { list } = buildList([
|
||||
['t1', 'b1'],
|
||||
['t2', 'b2'],
|
||||
]);
|
||||
collectTurnBlocks(list);
|
||||
const query = vi.spyOn(list, 'querySelectorAll');
|
||||
|
||||
// 一次滚动帧里读锚点、还原锚点会各查一遍:没失效就不该再碰 DOM。
|
||||
collectTurnBlocks(list);
|
||||
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
query.mockRestore();
|
||||
});
|
||||
|
||||
it('子元素变化并失效后重新收集', () => {
|
||||
const { list } = buildList([['t1', 'b1']]);
|
||||
const before = collectTurnBlocks(list);
|
||||
const extra = document.createElement('div');
|
||||
extra.dataset.turnKey = 't2';
|
||||
extra.dataset.blockKey = 'b2';
|
||||
list.appendChild(extra);
|
||||
|
||||
invalidateTurnBlocks(list);
|
||||
|
||||
const after = collectTurnBlocks(list);
|
||||
expect(after).toHaveLength(2);
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTopVisibleTurnAnchor', () => {
|
||||
it('跳过完全滚出顶部的块,取第一条真正露出的块', () => {
|
||||
const { list, blocks } = buildList([
|
||||
['t1', 'b1'],
|
||||
['t2', 'b2'],
|
||||
['t3', 'b3'],
|
||||
]);
|
||||
stubRect(blocks[0]!, { top: -120, bottom: -100 });
|
||||
stubRect(blocks[1]!, { top: -10, bottom: 30 });
|
||||
stubRect(blocks[2]!, { top: 40, bottom: 90 });
|
||||
|
||||
// 半条消息也算"正在读的位置":锚点必须是它,拿下一整条会把半条顶出视口。
|
||||
expect(readTopVisibleTurnAnchor(list)).toEqual({
|
||||
key: 't2',
|
||||
blockKey: 'b2',
|
||||
offset: -10,
|
||||
});
|
||||
});
|
||||
|
||||
it('同一个回合的第二个块露出时取的是它自己的块身份', () => {
|
||||
const { list, blocks } = buildList([
|
||||
['t1', 'b1'],
|
||||
['t2', 'b2'],
|
||||
['t2', 'b3'],
|
||||
]);
|
||||
stubRect(blocks[0]!, { top: -200, bottom: -150 });
|
||||
stubRect(blocks[1]!, { top: -140, bottom: -60 });
|
||||
stubRect(blocks[2]!, { top: -20, bottom: 40 });
|
||||
|
||||
expect(readTopVisibleTurnAnchor(list)).toEqual({
|
||||
key: 't2',
|
||||
blockKey: 'b3',
|
||||
offset: -20,
|
||||
});
|
||||
});
|
||||
|
||||
it('收起的 details 里的块没有布局盒:不拿它当锚点', () => {
|
||||
const { list, blocks } = buildList([
|
||||
['t2', 'process'],
|
||||
['t2', 'g1'],
|
||||
]);
|
||||
stubRect(blocks[0]!, { top: 100, bottom: 130 });
|
||||
// 被折进收起 `<details>` 的块在真实浏览器里矩形全 0:它既不是用户正在读的位置,
|
||||
// 也当不了还原基准。
|
||||
stubRect(blocks[1]!, { top: 0, bottom: 0 });
|
||||
|
||||
expect(readTopVisibleTurnAnchor(list)).toEqual({
|
||||
key: 't2',
|
||||
blockKey: 'process',
|
||||
offset: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('列表里没有块露出视口时没有锚点', () => {
|
||||
const { list, blocks } = buildList([['t1', 'b1']]);
|
||||
stubRect(blocks[0]!, { top: -50, bottom: -10 });
|
||||
expect(readTopVisibleTurnAnchor(list)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTurnBlock', () => {
|
||||
it('按回合 key 与块身份定位', () => {
|
||||
const { list, blocks } = buildList([
|
||||
['t1', 'b1'],
|
||||
['t2', 'b2'],
|
||||
['t2', 'b3'],
|
||||
]);
|
||||
expect(findTurnBlock(list, 't2', 'b3')).toBe(blocks[2]);
|
||||
expect(findTurnBlock(list, 't2', 'missing')).toBeNull();
|
||||
expect(findTurnBlock(list, 'missing', 'b2')).toBeNull();
|
||||
});
|
||||
|
||||
it('块序号变了不影响定位:收口把过程折进 details,序号整体后移', () => {
|
||||
const finished = buildList([
|
||||
['t2', 'u'],
|
||||
['t2', 'process'],
|
||||
['t2', 'g1'],
|
||||
['t2', 'g2'],
|
||||
['t2', 'f'],
|
||||
['t2', 'usage'],
|
||||
]);
|
||||
|
||||
// 按序号定位的实现会拿第 2 个块当 g2,而收口后它已经是 g1。
|
||||
expect(finished.blocks[2]!.dataset.blockKey).toBe('g1');
|
||||
expect(findTurnBlock(finished.list, 't2', 'g2')).toBe(finished.blocks[3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreTurnAnchor', () => {
|
||||
it('按块当前位置差改 scrollTop,让锚点回到原来的偏移', () => {
|
||||
const { list, blocks } = buildList([
|
||||
['t1', 'b1'],
|
||||
['t2', 'b2'],
|
||||
]);
|
||||
// 更早历史插进来之后,原本贴在视口顶边的 t2 被顶到下方 90px。
|
||||
stubRect(blocks[1]!, { top: 90, bottom: 140 });
|
||||
list.scrollTop = 0;
|
||||
const anchor: ConversationTurnAnchor = {
|
||||
key: 't2',
|
||||
blockKey: 'b2',
|
||||
offset: -10,
|
||||
};
|
||||
|
||||
expect(restoreTurnAnchor(list, anchor)).toBe(true);
|
||||
expect(list.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it('锚点块被折进收起的 details(没有布局盒):返回 false,不动 scrollTop', () => {
|
||||
const { list, blocks } = buildList([
|
||||
['t2', 'u'],
|
||||
['t2', 'process'],
|
||||
['t2', 'g1'],
|
||||
]);
|
||||
stubRect(blocks[0]!, { top: -200, bottom: -150 });
|
||||
stubRect(blocks[1]!, { top: -100, bottom: -70 });
|
||||
stubRect(blocks[2]!, { top: 0, bottom: 0 });
|
||||
list.scrollTop = 30;
|
||||
|
||||
expect(
|
||||
restoreTurnAnchor(list, { key: 't2', blockKey: 'g1', offset: -10 }),
|
||||
).toBe(false);
|
||||
expect(list.scrollTop).toBe(30);
|
||||
});
|
||||
|
||||
it('回合收口后按块身份仍还原到同一个块', () => {
|
||||
const running = buildList([
|
||||
['t2', 'u'],
|
||||
['t2', 'g1'],
|
||||
['t2', 'g2'],
|
||||
]);
|
||||
stubRect(running.blocks[0]!, { top: -200, bottom: -150 });
|
||||
stubRect(running.blocks[1]!, { top: -140, bottom: -60 });
|
||||
stubRect(running.blocks[2]!, { top: -20, bottom: 40 });
|
||||
const anchor = readTopVisibleTurnAnchor(running.list);
|
||||
expect(anchor).toEqual({ key: 't2', blockKey: 'g2', offset: -20 });
|
||||
|
||||
// 收口:过程折进 details,g2 的块序号从 1 变成 3;用户把过程展开后它有真实几何。
|
||||
const finished = buildList([
|
||||
['t2', 'u'],
|
||||
['t2', 'process'],
|
||||
['t2', 'g1'],
|
||||
['t2', 'g2'],
|
||||
['t2', 'f'],
|
||||
['t2', 'usage'],
|
||||
]);
|
||||
stubRect(finished.blocks[0]!, { top: -200, bottom: -150 });
|
||||
stubRect(finished.blocks[1]!, { top: -120, bottom: -90 });
|
||||
stubRect(finished.blocks[2]!, { top: -80, bottom: -20 });
|
||||
stubRect(finished.blocks[3]!, { top: 150, bottom: 250 });
|
||||
stubRect(finished.blocks[4]!, { top: 260, bottom: 300 });
|
||||
stubRect(finished.blocks[5]!, { top: 310, bottom: 340 });
|
||||
finished.list.scrollTop = 0;
|
||||
|
||||
expect(restoreTurnAnchor(finished.list, anchor!)).toBe(true);
|
||||
// 150 - 0 - (-20):按 g2 自己的位置算;按序号会错用 g1 的 -80,写出一副跳动的画面。
|
||||
expect(finished.list.scrollTop).toBe(170);
|
||||
});
|
||||
|
||||
it('锚点对应的块已经不在了:返回 false,不动 scrollTop', () => {
|
||||
const { list } = buildList([['t1', 'b1']]);
|
||||
list.scrollTop = 30;
|
||||
|
||||
expect(
|
||||
restoreTurnAnchor(list, { key: 'missing', blockKey: 'b1', offset: -10 }),
|
||||
).toBe(false);
|
||||
expect(list.scrollTop).toBe(30);
|
||||
});
|
||||
});
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 更早历史前插时的视口锚点:读取与还原。
|
||||
*
|
||||
* 锚点粒度是「回合 key(`data-turn-key`)+ 块身份(`data-block-key`)+ 相对列表顶边的像素偏移」。
|
||||
* 块身份就是 `DirectChatBlock.key`(条目块是 `${回合 key}:${条目 itemId}`,本地说明块是
|
||||
* `messageId`),只要求在同一回合内唯一;「执行过程」包装块与终态文案各有一个回合内唯一的
|
||||
* 字面量块身份(见 `DirectProjectTurn.tsx`)。
|
||||
*
|
||||
* 为什么不存块序号:`DirectProjectTurn` 对运行中的回合把过程块平铺、对已结束的回合把它们折进
|
||||
* 一个 `<details>`,收口时整个回合的块序号都会后移(`[用户, 工具组1, 工具组2]` →
|
||||
* `[用户, 执行过程, 工具组1, 工具组2, 终态正文, 终态文案]`),序号锚点会解析到隔壁块。
|
||||
* 合并更早历史只把新回合插在更早的位置,块身份跨前插稳定。还原只改 `scrollTop`。
|
||||
*
|
||||
* 块没有布局盒(被折进**收起**的 `<details>`、或已不可见)时不参与:读锚点时跳过它,还原时
|
||||
* 直接判失败(返回 false),由调用方按当前位置重新起锚,而不是拿 0 当基准写 `scrollTop`。
|
||||
*
|
||||
* 为什么不用浏览器原生 scroll anchoring:原生补偿只能让"当前可见内容"不动,做不到「跟随时
|
||||
* 展开要贴底」,两套补偿同时生效还会互相抵消;列表因此显式关闭原生锚定,补偿只走这一份。
|
||||
*/
|
||||
|
||||
export type ConversationTurnAnchor = {
|
||||
/** 回合身份:`DirectChatTurn.key`。 */
|
||||
key: string;
|
||||
/** 块身份:`data-block-key`,同一个回合内唯一。 */
|
||||
blockKey: string;
|
||||
/** 该块顶边相对列表顶边的偏移,可为负。 */
|
||||
offset: number;
|
||||
};
|
||||
|
||||
/** 可锚定的块:同时带回合 key 与块身份。两者缺一都定位不回来,干脆不进候选集合。 */
|
||||
const TURN_BLOCK_SELECTOR = '[data-turn-key][data-block-key]';
|
||||
|
||||
function turnKeyOf(element: Element): string | null {
|
||||
return element instanceof HTMLElement
|
||||
? (element.dataset.turnKey ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 块集合缓存:一次滚动或一次补偿里读锚点、还原锚点会各查一遍整个列表,长会话下就是每个滚动
|
||||
* 帧的 O(块数) DOM 查询。失效信号是列表子元素增删——只有子元素变化才可能改变「哪些块带回合
|
||||
* 身份」,而 `useConversationScroll` 的 `MutationObserver` 已经在听同一个信号。
|
||||
*/
|
||||
const turnBlockCache = new WeakMap<HTMLElement, HTMLElement[]>();
|
||||
|
||||
/** 列表里所有带回合身份的块,按文档顺序;子元素没变时复用上一次的结果。 */
|
||||
export function collectTurnBlocks(list: HTMLElement): HTMLElement[] {
|
||||
const cached = turnBlockCache.get(list);
|
||||
if (cached) return cached;
|
||||
const blocks = Array.from(
|
||||
list.querySelectorAll<HTMLElement>(TURN_BLOCK_SELECTOR),
|
||||
);
|
||||
turnBlockCache.set(list, blocks);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** 列表子元素变了(回合追加、历史前插、加载行挂卸):丢掉缓存,下次重新收集。 */
|
||||
export function invalidateTurnBlocks(list: HTMLElement): void {
|
||||
turnBlockCache.delete(list);
|
||||
}
|
||||
|
||||
/** 按「回合 key + 块身份」定位具体块;找不到返回 null。 */
|
||||
export function findTurnBlock(
|
||||
list: HTMLElement,
|
||||
key: string,
|
||||
blockKey: string,
|
||||
): HTMLElement | null {
|
||||
return (
|
||||
collectTurnBlocks(list).find(
|
||||
(block) =>
|
||||
turnKeyOf(block) === key && block.dataset.blockKey === blockKey,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部第一条真正露出视口的块。
|
||||
*
|
||||
* 用 `bottom > 列表顶边` 而不是 `top >= 列表顶边`:滚到中间时最上面那条通常只露出一半,
|
||||
* 它才是"用户正在读的位置",拿下一整条当锚点会在前插时把半条消息顶出去。
|
||||
*/
|
||||
export function readTopVisibleTurnAnchor(
|
||||
list: HTMLElement,
|
||||
): ConversationTurnAnchor | null {
|
||||
const listTop = list.getBoundingClientRect().top;
|
||||
|
||||
// TODO(perf): 这个循环最坏是 O(锚点之前的块数) 次 `getBoundingClientRect()`,调用点是 `onScroll`
|
||||
// (离底时)与每次 `compensateLayout`,超长历史里属于 low 级别的量测开销——同一帧里第一次调用
|
||||
// 之后布局已经干净,后续调用不会各自再触发一次 reflow,所以不是「每帧几百次强制布局」的振荡源。
|
||||
// 真要省掉遍历只能按「块在文档顺序里位置单调递增」做二分、或从上次命中处续扫;但被折进**收起**
|
||||
// `<details>` 的块矩形全 0、同样在候选集合里,会打断这个单调性,二分前得先定「隐藏块怎么参与」,
|
||||
// 而给锚点集合加「只收可见块」的规则又要每次子元素变化先量一遍,等于把省下的遍历又搬回来。
|
||||
// 若真机在超长历史里量到滚动手感问题,先走「缓存上次命中的块身份 + 从它附近续扫」这条不破坏
|
||||
// 单调性假设的路,而不是 rAF 节流(滚动事件本就按帧派发,同一帧去重收益极小)。
|
||||
for (const block of collectTurnBlocks(list)) {
|
||||
const rect = block.getBoundingClientRect();
|
||||
// 折进收起 `<details>` 的块在真实浏览器里没有布局盒(矩形全 0):它既不是用户正在读的
|
||||
// 位置,也当不了还原基准。
|
||||
if (rect.height <= 0) continue;
|
||||
if (rect.bottom - listTop <= 0) continue;
|
||||
|
||||
const key = turnKeyOf(block);
|
||||
const blockKey = block.dataset.blockKey;
|
||||
if (!key || !blockKey) continue;
|
||||
|
||||
return { key, blockKey, offset: rect.top - listTop };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按锚点还原 `scrollTop`,让同一个块回到原来的偏移。
|
||||
*
|
||||
* 返回是否还原成功:锚点对应的块已经不在列表里、或它已经被折进收起的 `<details>`(矩形全 0,
|
||||
* 算不出可用偏移)时返回 false,调用方据此拿当前位置重新起锚,而不是拿着过期基准改 `scrollTop`。
|
||||
*/
|
||||
export function restoreTurnAnchor(
|
||||
list: HTMLElement,
|
||||
anchor: ConversationTurnAnchor,
|
||||
): boolean {
|
||||
const block = findTurnBlock(list, anchor.key, anchor.blockKey);
|
||||
if (!block) return false;
|
||||
|
||||
const listTop = list.getBoundingClientRect().top;
|
||||
const rect = block.getBoundingClientRect();
|
||||
if (rect.height <= 0) return false;
|
||||
|
||||
list.scrollTop += rect.top - listTop - anchor.offset;
|
||||
return true;
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type ConversationHistoryLoadGate,
|
||||
DIRECT_HISTORY_TOP_THRESHOLD,
|
||||
DIRECT_SCROLL_BOTTOM_THRESHOLD,
|
||||
distanceFromBottom,
|
||||
isNearBottom,
|
||||
isNearHistoryTop,
|
||||
needsViewportFill,
|
||||
scrollToBottomLabel,
|
||||
shouldFillViewport,
|
||||
shouldLoadEarlierOnScroll,
|
||||
terminalContentSignature,
|
||||
} from './conversationScrollPolicy';
|
||||
|
||||
const metrics = (
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
) => ({ scrollTop, scrollHeight, clientHeight });
|
||||
|
||||
const openGate: ConversationHistoryLoadGate = {
|
||||
historyHasMore: true,
|
||||
historyLoading: false,
|
||||
historyError: null,
|
||||
};
|
||||
|
||||
describe('阈值', () => {
|
||||
it('触顶阈值必须小于贴底阈值', () => {
|
||||
// 相等时刚往下滚一点就会同时被判成「触顶」和「在底部」,历史加载与跟随最新互相打架。
|
||||
expect(DIRECT_HISTORY_TOP_THRESHOLD).toBeLessThan(
|
||||
DIRECT_SCROLL_BOTTOM_THRESHOLD,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNearBottom', () => {
|
||||
it('距底恰好等于阈值算在底部,超出一点就不算', () => {
|
||||
expect(isNearBottom(metrics(0, 400 + 48, 400))).toBe(true);
|
||||
expect(isNearBottom(metrics(0, 400 + 49, 400))).toBe(false);
|
||||
});
|
||||
|
||||
it('内容比视口还短时按在底部处理', () => {
|
||||
expect(distanceFromBottom(metrics(0, 120, 400))).toBe(0);
|
||||
expect(isNearBottom(metrics(0, 120, 400))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNearHistoryTop', () => {
|
||||
it('只在清单顶部阈值以内算触顶', () => {
|
||||
const atTop = metrics(DIRECT_HISTORY_TOP_THRESHOLD, 900, 400);
|
||||
const oneMorePx = metrics(DIRECT_HISTORY_TOP_THRESHOLD + 1, 900, 400);
|
||||
expect(isNearHistoryTop(atTop)).toBe(true);
|
||||
expect(isNearHistoryTop(oneMorePx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsViewportFill', () => {
|
||||
it('内容高度不超过视口高度就是还没铺满', () => {
|
||||
expect(needsViewportFill(metrics(0, 400, 400))).toBe(true);
|
||||
expect(needsViewportFill(metrics(0, 401, 400))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('自动加载的门', () => {
|
||||
it('滚动触顶且门全开才加载', () => {
|
||||
expect(shouldLoadEarlierOnScroll(metrics(0, 900, 400), openGate)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldLoadEarlierOnScroll(metrics(25, 900, 400), openGate)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('没有更早历史 / 正在加载 / 失败挂起,三种情况都不再自动触发', () => {
|
||||
const atTop = metrics(0, 900, 400);
|
||||
expect(
|
||||
shouldLoadEarlierOnScroll(atTop, {
|
||||
...openGate,
|
||||
historyHasMore: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLoadEarlierOnScroll(atTop, { ...openGate, historyLoading: true }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLoadEarlierOnScroll(atTop, {
|
||||
...openGate,
|
||||
historyError: '读取失败',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('填充视口与滚动触发共用同一道门', () => {
|
||||
const short = metrics(0, 200, 400);
|
||||
expect(shouldFillViewport(short, openGate)).toBe(true);
|
||||
expect(shouldFillViewport(metrics(0, 900, 400), openGate)).toBe(false);
|
||||
expect(
|
||||
shouldFillViewport(short, { ...openGate, historyError: '读取失败' }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('回到底部文案', () => {
|
||||
it('有新回复才换文案', () => {
|
||||
expect(scrollToBottomLabel(false)).toBe('回到底部');
|
||||
expect(scrollToBottomLabel(true)).toBe('有新回复 · 回到底部');
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminalContentSignature', () => {
|
||||
const turn = (key: string, users: number, finals: number) => ({
|
||||
key,
|
||||
users: Array.from({ length: users }, (_, index) => index),
|
||||
finals: Array.from({ length: finals }, (_, index) => index),
|
||||
});
|
||||
|
||||
it('空列表没有指纹', () => {
|
||||
expect(terminalContentSignature([])).toBe('');
|
||||
});
|
||||
|
||||
it('往前插更早历史不改变指纹', () => {
|
||||
const latest = turn('t2', 1, 1);
|
||||
expect(terminalContentSignature([latest])).toBe(
|
||||
terminalContentSignature([turn('t1', 1, 1), latest]),
|
||||
);
|
||||
});
|
||||
|
||||
it('最后一轮多出用户气泡或助手终态都算新内容', () => {
|
||||
const base = turn('t2', 1, 1);
|
||||
expect(terminalContentSignature([turn('t2', 1, 2)])).not.toBe(
|
||||
terminalContentSignature([base]),
|
||||
);
|
||||
expect(terminalContentSignature([turn('t2', 2, 1)])).not.toBe(
|
||||
terminalContentSignature([base]),
|
||||
);
|
||||
});
|
||||
});
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「有新回复」的判据:最后一轮的**终态内容**(用户气泡 + 助手最终答复)指纹。
|
||||
*
|
||||
* 只取最后一轮的数量,不取全部回合:更早历史是往前插的,用全量指纹会把「加载更早历史成功」
|
||||
* 误报成「有新回复」。同一轮里过程块继续增长也不算——用户要的是"有没有新内容可说",
|
||||
* 不是"这一轮跑了多久"。
|
||||
*/
|
||||
export function terminalContentSignature(
|
||||
turns: readonly { key: string; users: unknown[]; finals: unknown[] }[],
|
||||
): string {
|
||||
const last = turns[turns.length - 1];
|
||||
if (!last) return '';
|
||||
return `${last.key}:${last.users.length}:${last.finals.length}`;
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
freezeToggleHead,
|
||||
resolveToggleBody,
|
||||
resolveToggleHead,
|
||||
toggleRevealDelta,
|
||||
} from './conversationToggleReveal';
|
||||
|
||||
type StubRect = { top: number; bottom: number };
|
||||
|
||||
function stubRect(element: HTMLElement, rect: StubRect): void {
|
||||
element.getBoundingClientRect = () =>
|
||||
({
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
height: rect.bottom - rect.top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: rect.top,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一块折叠区的两种形态:
|
||||
* - `<details>`(回合的「执行过程」);
|
||||
* - `[aria-expanded]` 按钮 + `aria-controls` 正文(工具组头、「思考过程」)。
|
||||
*/
|
||||
function buildFixture() {
|
||||
document.body.innerHTML = `
|
||||
<div id="list">
|
||||
<details id="turn-fold">
|
||||
<summary id="turn-head">执行过程</summary>
|
||||
<div id="turn-body">过程正文</div>
|
||||
</details>
|
||||
<section>
|
||||
<button id="tool-head" type="button" aria-expanded="true" aria-controls="tool-body">
|
||||
<span id="tool-hit">工具组</span>
|
||||
</button>
|
||||
<div id="tool-body">工具正文</div>
|
||||
</section>
|
||||
<div id="plain"><span id="plain-hit">不是折叠头</span></div>
|
||||
</div>`;
|
||||
const list = document.getElementById('list') as HTMLDivElement;
|
||||
stubRect(list, { top: 0, bottom: 400 });
|
||||
return {
|
||||
list,
|
||||
turnHead: document.getElementById('turn-head') as HTMLElement,
|
||||
turnBody: document.getElementById('turn-body') as HTMLElement,
|
||||
toolHead: document.getElementById('tool-head') as HTMLElement,
|
||||
toolBody: document.getElementById('tool-body') as HTMLElement,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('resolveToggleHead', () => {
|
||||
it('summary 与 aria-expanded 两种折叠头都要认出来', () => {
|
||||
buildFixture();
|
||||
const turnHead = document.getElementById('turn-head');
|
||||
expect(resolveToggleHead(turnHead)?.tagName).toBe('SUMMARY');
|
||||
expect(resolveToggleHead(document.getElementById('tool-hit'))?.id).toBe(
|
||||
'tool-head',
|
||||
);
|
||||
expect(resolveToggleHead(document.getElementById('plain-hit'))).toBeNull();
|
||||
expect(resolveToggleHead(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveToggleBody', () => {
|
||||
it('summary 取 details 里第一个非 summary 子元素', () => {
|
||||
const { turnHead, turnBody } = buildFixture();
|
||||
expect(resolveToggleBody(turnHead)).toBe(turnBody);
|
||||
});
|
||||
|
||||
it('按钮按 aria-controls 找正文,没有就返回 null', () => {
|
||||
const { toolHead, toolBody } = buildFixture();
|
||||
expect(resolveToggleBody(toolHead)).toBe(toolBody);
|
||||
const bare = document.createElement('button');
|
||||
bare.setAttribute('aria-expanded', 'true');
|
||||
expect(resolveToggleBody(bare)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleRevealDelta', () => {
|
||||
const listRect = { top: 0, bottom: 400 };
|
||||
|
||||
it('头部没动、正文完整放得下:不滚动', () => {
|
||||
expect(
|
||||
toggleRevealDelta({
|
||||
listRect,
|
||||
headRect: { top: 100, bottom: 120 },
|
||||
headTop: 100,
|
||||
bodyRect: { top: 120, bottom: 300, height: 180 },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('正文比视口矮但底边超出视口:只滚到刚好露出底边', () => {
|
||||
// 头部在视口下缘,刚展开的 200px 正文全部长在视口外:只补这 120px 的超出量。
|
||||
expect(
|
||||
toggleRevealDelta({
|
||||
listRect,
|
||||
headRect: { top: 300, bottom: 320 },
|
||||
headTop: 300,
|
||||
bodyRect: { top: 320, bottom: 520, height: 200 },
|
||||
}),
|
||||
).toBe(120);
|
||||
});
|
||||
|
||||
it('正文比视口还高且底边超出:对齐正文顶边,让用户从正文开头读起', () => {
|
||||
expect(
|
||||
toggleRevealDelta({
|
||||
listRect,
|
||||
headRect: { top: 300, bottom: 320 },
|
||||
headTop: 300,
|
||||
bodyRect: { top: 320, bottom: 900, height: 580 },
|
||||
}),
|
||||
).toBe(320);
|
||||
});
|
||||
|
||||
it('收起(正文高度归零)不额外滚动,只按头部位移补偿', () => {
|
||||
expect(
|
||||
toggleRevealDelta({
|
||||
listRect,
|
||||
headRect: { top: 10, bottom: 30 },
|
||||
headTop: -40,
|
||||
bodyRect: { top: 0, bottom: 0, height: 0 },
|
||||
}),
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it('上方内容把头部顶下去后正文已露在视口里:不再往回滚', () => {
|
||||
expect(
|
||||
toggleRevealDelta({
|
||||
listRect,
|
||||
headRect: { top: 10, bottom: 30 },
|
||||
headTop: -40,
|
||||
bodyRect: { top: 40, bottom: 600, height: 560 },
|
||||
}),
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it('解析不出正文:只按头部位移补偿', () => {
|
||||
expect(
|
||||
toggleRevealDelta({
|
||||
listRect,
|
||||
headRect: { top: 10, bottom: 30 },
|
||||
headTop: 10,
|
||||
bodyRect: null,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('freezeToggleHead', () => {
|
||||
it('头部仍在视口里:按位移把头部拉回展开前的屏幕位置', () => {
|
||||
const { list, turnHead } = buildFixture();
|
||||
stubRect(turnHead, { top: 10, bottom: 30 });
|
||||
list.scrollTop = 0;
|
||||
|
||||
expect(freezeToggleHead(list, { head: turnHead, headTop: -40 })).toBe(true);
|
||||
expect(list.scrollTop).toBe(50);
|
||||
});
|
||||
|
||||
it('展开正文比视口矮但底边超出视口:滚到刚好露出正文底边', () => {
|
||||
const { list, turnHead, turnBody } = buildFixture();
|
||||
stubRect(turnHead, { top: 300, bottom: 320 });
|
||||
stubRect(turnBody, { top: 320, bottom: 520 });
|
||||
list.scrollTop = 0;
|
||||
|
||||
expect(freezeToggleHead(list, { head: turnHead, headTop: 300 })).toBe(true);
|
||||
expect(list.scrollTop).toBe(120);
|
||||
});
|
||||
|
||||
it('展开正文比视口还高且底边超出:对齐正文顶边', () => {
|
||||
const { list, turnHead, turnBody } = buildFixture();
|
||||
stubRect(turnHead, { top: 300, bottom: 320 });
|
||||
stubRect(turnBody, { top: 320, bottom: 900 });
|
||||
list.scrollTop = 0;
|
||||
|
||||
expect(freezeToggleHead(list, { head: turnHead, headTop: 300 })).toBe(true);
|
||||
expect(list.scrollTop).toBe(320);
|
||||
});
|
||||
|
||||
it('头部已经不在视口里:不补偿,交给锚点还原', () => {
|
||||
const { list, turnHead } = buildFixture();
|
||||
stubRect(turnHead, { top: 450, bottom: 480 });
|
||||
list.scrollTop = 20;
|
||||
|
||||
expect(freezeToggleHead(list, { head: turnHead, headTop: 100 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(list.scrollTop).toBe(20);
|
||||
});
|
||||
|
||||
it('头部已经卸载:不补偿', () => {
|
||||
const { list, turnHead } = buildFixture();
|
||||
stubRect(turnHead, { top: 10, bottom: 30 });
|
||||
turnHead.remove();
|
||||
list.scrollTop = 0;
|
||||
|
||||
expect(freezeToggleHead(list, { head: turnHead, headTop: 10 })).toBe(false);
|
||||
expect(list.scrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 折叠块展开 / 收起后的视口处理。
|
||||
*
|
||||
* 展开一个 `<details>` 或 `[aria-expanded]` 块不会改变 `scrollTop`,但会把正文长在视口下方:
|
||||
* 用户必须再滚一次才能看到刚展开的内容。这里的做法是:
|
||||
* 1. 头部仍在视口里 → 把它拉回展开前的屏幕位置(折叠头冻结);
|
||||
* 2. 展开正文比视口还高、头部已经锚不住 → 对齐正文顶边,让用户从正文开头读起。
|
||||
*
|
||||
* 判断所需的"展开前位置"由调用方在点击捕获阶段记录,见 `useConversationScroll`。
|
||||
*/
|
||||
|
||||
export type ConversationToggleFold = {
|
||||
/** 触发折叠的头部元素:`<details>` 的 `summary`,或带 `aria-expanded` 的按钮。 */
|
||||
head: HTMLElement;
|
||||
/** 头部在展开前相对视口顶边的位置(`getBoundingClientRect().top`)。 */
|
||||
headTop: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从点击目标向上找折叠头。
|
||||
*
|
||||
* 两种折叠头都要认:`DirectProjectTurn` 的「执行过程」是 `<details>`,`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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开 / 收起一个折叠块需要的总位移。纯函数:所有几何由调用方测量后传进来。
|
||||
*
|
||||
* 两段合成一次位移,不能分两次写 `scrollTop`:
|
||||
* 1. 折叠头冻结——把它拉回展开前的屏幕位置(`headTop` 是点击捕获阶段记下的);
|
||||
* 2. 露出新展开的正文——正文底边在视口下方就补上超出量;正文比视口还高就对正文顶边,
|
||||
* 让用户从正文开头读起。
|
||||
*
|
||||
* 只补「刚好露出」的最小位移:正文比视口矮的时候,直接把正文滚进视口会把画面大幅上移,
|
||||
* 打断正在读历史的人。正文已经完全落在视口里(或已收起、高度归零)时就只冻头部。
|
||||
*/
|
||||
export type ConversationToggleRevealGeometry = {
|
||||
listRect: { top: number; bottom: number };
|
||||
headRect: { top: number; bottom: number };
|
||||
/** 折叠头在展开前相对视口顶边的位置。 */
|
||||
headTop: number;
|
||||
/** 折叠正文的几何;解析不出正文、或已收起传 null。 */
|
||||
bodyRect: { top: number; bottom: number; height: number } | null;
|
||||
};
|
||||
|
||||
export function toggleRevealDelta(
|
||||
input: ConversationToggleRevealGeometry,
|
||||
): number {
|
||||
const freeze = input.headRect.top - input.headTop;
|
||||
const body = input.bodyRect;
|
||||
if (!body || body.height <= 0) return freeze;
|
||||
|
||||
// 冻结之后再判正文位置:位移已经把正文整体上移了 freeze 像素。
|
||||
const bodyTop = body.top - freeze;
|
||||
const bodyBottom = body.bottom - freeze;
|
||||
const viewportHeight = input.listRect.bottom - input.listRect.top;
|
||||
if (body.height > viewportHeight && bodyBottom > input.listRect.bottom) {
|
||||
return freeze + Math.max(0, bodyTop - input.listRect.top);
|
||||
}
|
||||
if (bodyBottom > input.listRect.bottom) {
|
||||
return freeze + (bodyBottom - input.listRect.bottom);
|
||||
}
|
||||
return freeze;
|
||||
}
|
||||
|
||||
/**
|
||||
* 布局变化后把折叠头拉回展开前的位置;返回是否做了补偿。
|
||||
*
|
||||
* 头部已经不在视口里时返回 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;
|
||||
|
||||
const body = resolveToggleBody(fold.head);
|
||||
list.scrollTop += toggleRevealDelta({
|
||||
listRect,
|
||||
headRect,
|
||||
headTop: fold.headTop,
|
||||
bodyRect: body?.getBoundingClientRect() ?? null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
+584
File diff suppressed because it is too large
Load Diff
+347
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
type MouseEventHandler,
|
||||
type RefObject,
|
||||
type UIEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
|
||||
import {
|
||||
type ConversationTurnAnchor,
|
||||
invalidateTurnBlocks,
|
||||
readTopVisibleTurnAnchor,
|
||||
restoreTurnAnchor,
|
||||
} from './conversationScrollAnchor';
|
||||
import {
|
||||
HISTORY_LOADING_INDICATOR_DELAY_MS,
|
||||
isNearBottom,
|
||||
readConversationListMetrics,
|
||||
scrollToBottomLabel,
|
||||
shouldFillViewport,
|
||||
shouldLoadEarlierOnScroll,
|
||||
terminalContentSignature,
|
||||
} from './conversationScrollPolicy';
|
||||
import {
|
||||
type ConversationToggleFold,
|
||||
freezeToggleHead,
|
||||
resolveToggleHead,
|
||||
} from './conversationToggleReveal';
|
||||
import { useDelayedFlag } from './useDelayedFlag';
|
||||
|
||||
/**
|
||||
* 会话列表的滚动所有权:跟随最新、更早历史自动加载、前插锚定、折叠补偿与回到底部胶囊。
|
||||
*
|
||||
* 这一层只碰列表这一个滚动容器,读的几何全部来自真实布局;所有判据(阈值、文案、合并门)
|
||||
* 都在 `conversationScrollPolicy.ts` 的纯函数里,策略改动不需要动这里的状态机。契约见
|
||||
* `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`。
|
||||
*
|
||||
* 为什么集中在一处:贴底跟随、前插保锚点、展开补偿是同一份 `scrollTop` 上的三套补偿,
|
||||
* 分散到视图里就会互相覆盖(同一帧里谁后写谁赢,表现为间歇性跳动)。
|
||||
*/
|
||||
export type ConversationScrollOptions = {
|
||||
/**
|
||||
* 会话身份键(DirectProject 传项目路径)。
|
||||
*
|
||||
* 列表容器在切项目时不会重挂载,滚动所有权却只在挂载时初始化一次:上一个会话的「已离开
|
||||
* 底部」会漏到新会话——首屏不贴底,新会话的第一批回合还会被误判成「有新回复」。身份一变
|
||||
* 就把这批状态复位并重新贴底,而不是靠重建组件(见 ADR 第 6 条)。
|
||||
*/
|
||||
conversationKey: string;
|
||||
/** 已渲染的回合投影:内容变化既驱动贴底,也驱动「有新回复」。 */
|
||||
turns: readonly DirectChatTurn[];
|
||||
historyHasMore: boolean;
|
||||
historyLoading: boolean;
|
||||
historyError: string | null;
|
||||
/** 这一轮在飞(`DirectProjectTurnStatus.displayBusy`):新回合开始要强制回到最新。 */
|
||||
turnInFlight: boolean;
|
||||
onLoadEarlierHistory: () => void;
|
||||
};
|
||||
|
||||
export type ConversationScrollBinding = {
|
||||
listRef: RefObject<HTMLDivElement | null>;
|
||||
onScroll: UIEventHandler<HTMLDivElement>;
|
||||
/** 点击捕获:记录折叠头展开前的屏幕位置,供布局变化后的补偿用。 */
|
||||
onToggleCapture: MouseEventHandler<HTMLDivElement>;
|
||||
scrollToBottom: () => void;
|
||||
showScrollToBottom: boolean;
|
||||
scrollToBottomLabel: string;
|
||||
/** 延迟 150ms 后的加载态:直接挂载会在本地瞬时读取时闪一下。 */
|
||||
showHistoryLoading: boolean;
|
||||
};
|
||||
|
||||
function scrollListToBottom(
|
||||
list: HTMLDivElement | null,
|
||||
behavior: ScrollBehavior,
|
||||
): void {
|
||||
if (!list) return;
|
||||
if (behavior === 'smooth' && typeof list.scrollTo === 'function') {
|
||||
list.scrollTo({ top: list.scrollHeight, behavior });
|
||||
return;
|
||||
}
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
export function useConversationScroll({
|
||||
conversationKey,
|
||||
turns,
|
||||
historyHasMore,
|
||||
historyLoading,
|
||||
historyError,
|
||||
turnInFlight,
|
||||
onLoadEarlierHistory,
|
||||
}: ConversationScrollOptions): ConversationScrollBinding {
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
/** 跟随时任何高度变化都贴底;用户离开底部(或点胶囊)即翻转。 */
|
||||
const followLatestRef = useRef(true);
|
||||
/**
|
||||
* 程序化平滑滚动在飞吗。
|
||||
*
|
||||
* 为真时滚动位置归这次动画所有:滚动事件不翻转跟随、布局补偿不写 `scrollTop`。
|
||||
* 真实浏览器里只要写一次 `scrollTop`,正在跑的平滑动画就被取消——补偿与锚点还原都是写
|
||||
* `scrollTop`,所以「点回到底部只下去一屏就停住」的直接原因就在这里。
|
||||
* 滚到贴底阈值、或用户用滚轮 / 触摸 / 键盘接手时交还。
|
||||
*/
|
||||
const programmaticScrollRef = useRef(false);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const [hasNewReply, setHasNewReply] = useState(false);
|
||||
/** 前插时要保住的顶部可见回合;每次滚动与补偿后刷新。 */
|
||||
const preserveAnchorRef = useRef<ConversationTurnAnchor | null>(null);
|
||||
/** 刚刚展开 / 收起的折叠头;只被下一次布局补偿消费一次。 */
|
||||
const foldRef = useRef<ConversationToggleFold | null>(null);
|
||||
const turnInFlightRef = useRef(turnInFlight);
|
||||
const terminalSignatureRef = useRef(terminalContentSignature(turns));
|
||||
/** 上一次的会话身份:变了就复位滚动所有权(见 `conversationKey`)。 */
|
||||
const conversationKeyRef = useRef(conversationKey);
|
||||
/**
|
||||
* 更早历史是否正在读:加载期间锚点保持冻结——加载行挂载、历史合并这些回调都还原同一个
|
||||
* 锚点,加载结束后的下一次补偿才刷新(见 ADR 第 2 条),否则两次补偿会各按各的基准还原。
|
||||
*/
|
||||
const historyLoadingRef = useRef(historyLoading);
|
||||
useEffect(() => {
|
||||
historyLoadingRef.current = historyLoading;
|
||||
}, [historyLoading]);
|
||||
|
||||
const showHistoryLoading = useDelayedFlag(
|
||||
historyLoading,
|
||||
HISTORY_LOADING_INDICATOR_DELAY_MS,
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载入口过一手 ref:调用方(视图)每次渲染都会给一个新函数,直接进依赖数组会让填充视口的
|
||||
* 判据每次渲染都重跑一遍;这里只认最后一次给进来的那一个。
|
||||
*/
|
||||
const loadEarlierRef = useRef(onLoadEarlierHistory);
|
||||
useEffect(() => {
|
||||
loadEarlierRef.current = onLoadEarlierHistory;
|
||||
}, [onLoadEarlierHistory]);
|
||||
const loadEarlier = useCallback(() => {
|
||||
loadEarlierRef.current();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 布局变化后的补偿:跟随时贴底;否则优先冻结刚展开的折叠头,再退到前插锚点。
|
||||
*
|
||||
* 顺序不能颠倒:展开的补偿比锚点更精确(锚点只保证"某条回合"不动,冻结保证"你点的那个头"
|
||||
* 不动),而锚点还原是前插场景唯一能用的手段。
|
||||
*/
|
||||
const compensateLayout = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
|
||||
// 程序化滚动在飞:滚动位置归这次动画所有,直接写 `scrollTop` 会把动画取消在半路。
|
||||
// 但内容在这期间变高(流式正文、图片撑开)时,点击瞬间记下的目标已经不是底部了——动画会
|
||||
// 停在旧目标上,而 `programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是跟随与补偿
|
||||
// 一直挂着、胶囊又已按「已贴底」隐掉。重新对准新的底部,让动画继续跑到真正的底。
|
||||
if (programmaticScrollRef.current) {
|
||||
if (followLatestRef.current) scrollListToBottom(list, 'smooth');
|
||||
return;
|
||||
}
|
||||
|
||||
if (followLatestRef.current) {
|
||||
scrollListToBottom(list, 'auto');
|
||||
return;
|
||||
}
|
||||
|
||||
const fold = foldRef.current;
|
||||
if (fold) {
|
||||
foldRef.current = null;
|
||||
if (freezeToggleHead(list, fold)) {
|
||||
// 补偿后刷新锚点:下一个回调(一次展开可能触发多次)落到同一条回合上,不会再动。
|
||||
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const anchor = preserveAnchorRef.current;
|
||||
if (!anchor) return;
|
||||
if (restoreTurnAnchor(list, anchor)) {
|
||||
// 加载期间不刷新:整段加载里的每次补偿都还原同一个锚点,加载结束后再重起一个。
|
||||
if (!historyLoadingRef.current) {
|
||||
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `<details>`、没有布局盒算不出偏移):
|
||||
// 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。
|
||||
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
|
||||
}, []);
|
||||
|
||||
// 换会话:列表容器不重挂载,滚动所有权必须显式复位,否则上一个会话的「已离开底部」
|
||||
// 会漏到新会话。必须先于下面那条「内容变化」的 effect:终态指纹要先同步成新会话的内容,
|
||||
// 否则同一次提交里的内容变化会被算成「新增回复」。
|
||||
useEffect(() => {
|
||||
if (conversationKeyRef.current === conversationKey) return;
|
||||
conversationKeyRef.current = conversationKey;
|
||||
programmaticScrollRef.current = false;
|
||||
followLatestRef.current = true;
|
||||
preserveAnchorRef.current = null;
|
||||
foldRef.current = null;
|
||||
terminalSignatureRef.current = terminalContentSignature(turns);
|
||||
setAtBottom(true);
|
||||
setHasNewReply(false);
|
||||
scrollListToBottom(listRef.current, 'auto');
|
||||
}, [conversationKey, turns]);
|
||||
|
||||
// 内容变化:跟随时贴底;不跟随时只在**终态内容**增加时点亮「有新回复」。
|
||||
useEffect(() => {
|
||||
const signature = terminalContentSignature(turns);
|
||||
const gainedTerminalContent = signature !== terminalSignatureRef.current;
|
||||
terminalSignatureRef.current = signature;
|
||||
if (followLatestRef.current) {
|
||||
scrollListToBottom(listRef.current, 'auto');
|
||||
return;
|
||||
}
|
||||
if (gainedTerminalContent) setHasNewReply(true);
|
||||
}, [turns]);
|
||||
|
||||
// 观察列表的直接子元素:展开 / 收起、流式正文增长、图片撑高、历史前插都只有这一个入口。
|
||||
// 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅,同时让锚点的块集合缓存失效。
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list || typeof ResizeObserver === 'undefined') return undefined;
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
const subscribe = () => {
|
||||
// 同一份子元素信号同时喂给两处:块集合缓存(锚点定位)与 ResizeObserver 的观察名单。
|
||||
invalidateTurnBlocks(list);
|
||||
observer?.disconnect();
|
||||
observer = new ResizeObserver(() => compensateLayout());
|
||||
for (const child of Array.from(list.children)) {
|
||||
observer.observe(child);
|
||||
}
|
||||
};
|
||||
subscribe();
|
||||
const mutations = new MutationObserver(subscribe);
|
||||
mutations.observe(list, { childList: true });
|
||||
|
||||
return () => {
|
||||
mutations.disconnect();
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [compensateLayout]);
|
||||
|
||||
// 程序化滚动被用户接手(滚轮 / 触摸 / 键盘)时立刻交还控制权。
|
||||
// 动画被打断后浏览器不会再发贴底滚动事件,标记不交还就会永远挂着,后续跟随与补偿全失效。
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return undefined;
|
||||
const release = () => {
|
||||
programmaticScrollRef.current = false;
|
||||
};
|
||||
list.addEventListener('wheel', release, { passive: true });
|
||||
list.addEventListener('touchstart', release, { passive: true });
|
||||
list.addEventListener('keydown', release);
|
||||
return () => {
|
||||
list.removeEventListener('wheel', release);
|
||||
list.removeEventListener('touchstart', release);
|
||||
list.removeEventListener('keydown', release);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 新一回合开始(假 → 真)强制恢复跟随:用户上一轮跑完时停在中途,这一轮不该再让他手动滚。
|
||||
useEffect(() => {
|
||||
const started = turnInFlight && !turnInFlightRef.current;
|
||||
turnInFlightRef.current = turnInFlight;
|
||||
if (!started) return;
|
||||
programmaticScrollRef.current = false;
|
||||
followLatestRef.current = true;
|
||||
setAtBottom(true);
|
||||
setHasNewReply(false);
|
||||
scrollListToBottom(listRef.current, 'auto');
|
||||
}, [turnInFlight]);
|
||||
|
||||
// 填充视口:首帧之后内容还没铺满,就继续往前拉,直到铺满或没有更早历史。
|
||||
// 依赖 `turns` 而不是定时器:每次加载落一批新回合就再判一次,同一条链路自己收敛。
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const metrics = readConversationListMetrics(list);
|
||||
if (
|
||||
!shouldFillViewport(metrics, {
|
||||
historyHasMore,
|
||||
historyLoading,
|
||||
historyError,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
loadEarlier();
|
||||
}, [historyError, historyHasMore, historyLoading, loadEarlier, turns]);
|
||||
|
||||
const onScroll: UIEventHandler<HTMLDivElement> = (event) => {
|
||||
const list = event.currentTarget;
|
||||
const metrics = readConversationListMetrics(list);
|
||||
const nearBottom = isNearBottom(metrics);
|
||||
if (programmaticScrollRef.current) {
|
||||
// 平滑滚动期间的中间帧不是用户意图:这里翻转跟随或换锚点,都会让补偿立刻把动画取消。
|
||||
if (!nearBottom) return;
|
||||
programmaticScrollRef.current = false;
|
||||
}
|
||||
followLatestRef.current = nearBottom;
|
||||
setAtBottom(nearBottom);
|
||||
if (nearBottom) setHasNewReply(false);
|
||||
else preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
|
||||
|
||||
if (
|
||||
shouldLoadEarlierOnScroll(metrics, {
|
||||
historyHasMore,
|
||||
historyLoading,
|
||||
historyError,
|
||||
})
|
||||
) {
|
||||
loadEarlier();
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleCapture: MouseEventHandler<HTMLDivElement> = (event) => {
|
||||
const list = listRef.current;
|
||||
const head = resolveToggleHead(event.target as Element | null);
|
||||
if (!list || !head || !list.contains(head)) return;
|
||||
foldRef.current = { head, headTop: head.getBoundingClientRect().top };
|
||||
};
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
followLatestRef.current = true;
|
||||
setAtBottom(true);
|
||||
setHasNewReply(false);
|
||||
const list = listRef.current;
|
||||
// 只有真的还要走一段距离才接管滚动位置:已经贴底的列表不会再有滚动事件,挂上标记就没人交还。
|
||||
if (list && !isNearBottom(readConversationListMetrics(list))) {
|
||||
programmaticScrollRef.current = true;
|
||||
}
|
||||
scrollListToBottom(list, 'smooth');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
listRef,
|
||||
onScroll,
|
||||
onToggleCapture,
|
||||
scrollToBottom,
|
||||
showScrollToBottom: !atBottom,
|
||||
scrollToBottomLabel: scrollToBottomLabel(hasNewReply),
|
||||
showHistoryLoading,
|
||||
};
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+14
@@ -42,6 +42,8 @@ export function ToolCallGroup({
|
||||
calls,
|
||||
active = false,
|
||||
className,
|
||||
turnKey,
|
||||
blockKey,
|
||||
}: {
|
||||
calls: DirectChatToolCard[];
|
||||
/**
|
||||
@@ -51,6 +53,16 @@ export function ToolCallGroup({
|
||||
*/
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点定位这一块
|
||||
* (见 `DirectProjectConversation/conversationScrollAnchor.ts`)。
|
||||
*/
|
||||
turnKey?: string;
|
||||
/**
|
||||
* 这一块的稳定块身份(`DirectChatBlock.key`):写进 `data-block-key`。锚点按「回合 key +
|
||||
* 块身份」定位,不按块序号——回合收口时过程块会被折进 `<details>`,序号会整体后移。
|
||||
*/
|
||||
blockKey?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const bodyId = useId();
|
||||
@@ -98,6 +110,8 @@ export function ToolCallGroup({
|
||||
}
|
||||
data-testid="agent-tool-call-group"
|
||||
aria-label="陶泥儿执行过程"
|
||||
data-turn-key={turnKey}
|
||||
data-block-key={blockKey}
|
||||
data-status={status}
|
||||
data-has-failure={hasFailure ? 'true' : undefined}
|
||||
data-duration-ms={timing.durationMs ?? ''}
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TauriInvoke } from '../../../../app/types';
|
||||
import type { HistorySlice } from '../generated/HistorySlice';
|
||||
import type { ThreadItem } from '../generated/ThreadItem';
|
||||
import { useDirectProjectChatController } from './useDirectProjectChatController';
|
||||
|
||||
type SliceRequest = {
|
||||
projectPath: string;
|
||||
beforeItemId: string | undefined;
|
||||
resolve: (slice: HistorySlice) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
function userItem(itemId: string): ThreadItem {
|
||||
return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 };
|
||||
}
|
||||
|
||||
function slice(
|
||||
items: ThreadItem[],
|
||||
hasMore: boolean,
|
||||
firstItemId: string | null,
|
||||
): HistorySlice {
|
||||
return { items, hasMore, firstItemId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 装一个可控的 Tauri invoke:历史切片读取全部挂起,用例自己决定哪一页何时落地 / 落空。
|
||||
*
|
||||
* `resolveTauriInvoke` 只读 `window.__TAURI__.core.invoke`,这里就是控制器唯一的外部接缝。
|
||||
*/
|
||||
function installDeferredHistoryInvoke() {
|
||||
const requests: SliceRequest[] = [];
|
||||
// `vi.fn` 的返回类型是固定签名,装不进 `window.__TAURI__.core.invoke` 的泛型签名,这里显式转换。
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== 'read_direct_project_history_slice') return undefined;
|
||||
return new Promise<HistorySlice>((resolve, reject) => {
|
||||
requests.push({
|
||||
projectPath: String(args?.projectPath ?? ''),
|
||||
beforeItemId: args?.beforeItemId as string | undefined,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
},
|
||||
) as unknown as TauriInvoke;
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
return {
|
||||
/** 取出一条挂起的读取;没有就等到出现为止。 */
|
||||
async take(
|
||||
projectPath: string,
|
||||
beforeItemId?: string,
|
||||
): Promise<SliceRequest> {
|
||||
let found: SliceRequest | undefined;
|
||||
await waitFor(() => {
|
||||
found = requests.find(
|
||||
(item) =>
|
||||
item.projectPath === projectPath &&
|
||||
item.beforeItemId === beforeItemId,
|
||||
);
|
||||
expect(found).toBeDefined();
|
||||
});
|
||||
return requests.splice(requests.indexOf(found!), 1)[0]!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderChatController(initialProjectPath: string) {
|
||||
const allowed = async () => true;
|
||||
return renderHook(
|
||||
({ projectPath }: { projectPath: string }) =>
|
||||
useDirectProjectChatController({
|
||||
enabled: true,
|
||||
ensureConversationReadAllowed: allowed,
|
||||
ensureConversationWriteAllowed: allowed,
|
||||
onRuntimeError: () => undefined,
|
||||
projectId: 'project-1',
|
||||
projectPath,
|
||||
refreshManifest: () => undefined,
|
||||
}),
|
||||
{ initialProps: { projectPath: initialProjectPath } },
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
describe('更早历史加载态的项目归属', () => {
|
||||
it('切项目后旧项目在飞的读取才落地,不能清掉新项目正在进行的加载态', async () => {
|
||||
const tauri = installDeferredHistoryInvoke();
|
||||
const { result, rerender } = renderChatController('/projects/a');
|
||||
|
||||
// 首屏:/a 的第一页落地,拿到「还有更早」。
|
||||
const firstScreenA = await tauri.take('/projects/a');
|
||||
await act(async () => {
|
||||
firstScreenA.resolve(slice([userItem('a2')], true, 'a2'));
|
||||
});
|
||||
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
|
||||
|
||||
// 用户滚到顶:/a 的更早历史读取挂起。
|
||||
await act(async () => {
|
||||
void result.current.loadEarlierHistory();
|
||||
});
|
||||
const staleRead = await tauri.take('/projects/a', 'a2');
|
||||
expect(result.current.historyLoading).toBe(true);
|
||||
|
||||
// 切到 /b:控制器重置加载态并重新拉首屏。
|
||||
rerender({ projectPath: '/projects/b' });
|
||||
const firstScreenB = await tauri.take('/projects/b');
|
||||
await act(async () => {
|
||||
firstScreenB.resolve(slice([userItem('b2')], true, 'b2'));
|
||||
});
|
||||
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
|
||||
|
||||
// /b 也发起更早历史的读取:加载态再次打开。
|
||||
await act(async () => {
|
||||
void result.current.loadEarlierHistory();
|
||||
});
|
||||
const currentRead = await tauri.take('/projects/b', 'b2');
|
||||
expect(result.current.historyLoading).toBe(true);
|
||||
|
||||
// 旧项目的读取此刻才落地:它属于 /a,不能清掉 /b 的加载态。
|
||||
await act(async () => {
|
||||
staleRead.resolve(slice([userItem('a1')], true, 'a1'));
|
||||
});
|
||||
expect(result.current.historyLoading).toBe(true);
|
||||
|
||||
// /b 自己的读取落地后,加载态由它自己收口。
|
||||
await act(async () => {
|
||||
currentRead.resolve(slice([userItem('b1')], false, 'b1'));
|
||||
});
|
||||
expect(result.current.historyLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('更早历史读取的世代守卫', () => {
|
||||
it('同一项目切走再切回后,上一世代的读取落地不能清掉新加载态', async () => {
|
||||
const tauri = installDeferredHistoryInvoke();
|
||||
const { result, rerender } = renderChatController('/projects/a');
|
||||
|
||||
// /a 首屏:拿到「还有更早」,游标停在 a2。
|
||||
const firstScreenA = await tauri.take('/projects/a');
|
||||
await act(async () => {
|
||||
firstScreenA.resolve(slice([userItem('a2')], true, 'a2'));
|
||||
});
|
||||
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
|
||||
|
||||
// 在 /a 滚到顶:第一条更早历史读取挂起。
|
||||
await act(async () => {
|
||||
void result.current.loadEarlierHistory();
|
||||
});
|
||||
const staleRead = await tauri.take('/projects/a', 'a2');
|
||||
expect(result.current.historyLoading).toBe(true);
|
||||
|
||||
// 切到 /b 再切回 /a:两次切项目都重置加载态,并各拉一次首屏。
|
||||
rerender({ projectPath: '/projects/b' });
|
||||
const firstScreenB = await tauri.take('/projects/b');
|
||||
await act(async () => {
|
||||
firstScreenB.resolve(slice([userItem('b2')], true, 'b2'));
|
||||
});
|
||||
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
|
||||
|
||||
rerender({ projectPath: '/projects/a' });
|
||||
const firstScreenA2 = await tauri.take('/projects/a');
|
||||
await act(async () => {
|
||||
firstScreenA2.resolve(slice([userItem('a2')], true, 'a2'));
|
||||
});
|
||||
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
|
||||
|
||||
// 回到 /a 后再滚到顶:新一代的更早历史读取挂起。
|
||||
await act(async () => {
|
||||
void result.current.loadEarlierHistory();
|
||||
});
|
||||
const currentRead = await tauri.take('/projects/a', 'a2');
|
||||
expect(result.current.historyLoading).toBe(true);
|
||||
|
||||
// 第一条 /a 读取此刻才落地:路径和现在一样,但它属于上一世代,不能收掉新一代的加载态。
|
||||
await act(async () => {
|
||||
staleRead.resolve(slice([userItem('a1')], false, 'a1'));
|
||||
});
|
||||
expect(result.current.historyLoading).toBe(true);
|
||||
|
||||
// 新一代自己的读取落地后,加载态由它自己收口。
|
||||
await act(async () => {
|
||||
currentRead.resolve(slice([userItem('a1')], false, 'a1'));
|
||||
});
|
||||
expect(result.current.historyLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('更早历史失败的挂起态', () => {
|
||||
it('宿主没给出失败消息时,挂起态仍是可判定的非空值', async () => {
|
||||
const tauri = installDeferredHistoryInvoke();
|
||||
const { result } = renderChatController('/projects/a');
|
||||
|
||||
const firstScreen = await tauri.take('/projects/a');
|
||||
await act(async () => {
|
||||
firstScreen.resolve(slice([userItem('a2')], true, 'a2'));
|
||||
});
|
||||
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
|
||||
|
||||
await act(async () => {
|
||||
void result.current.loadEarlierHistory();
|
||||
});
|
||||
const failing = await tauri.take('/projects/a', 'a2');
|
||||
await act(async () => {
|
||||
failing.reject(new Error(''));
|
||||
});
|
||||
|
||||
// `historyError` 同时是「挂起自动加载」的闸门与内联错误行的显隐开关:空串是 falsy,
|
||||
// 两者会一起消失,滚到顶就变成反复重试同一个失败读取。
|
||||
expect(result.current.historyError).toBeTruthy();
|
||||
expect(result.current.historyLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
+81
-13
@@ -44,6 +44,15 @@ import { useDirectThreadChatSubscription } from './useDirectThreadChatSubscripti
|
||||
export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8;
|
||||
export const DIRECT_HISTORY_PAGE_SIZE = CONVERSATION_VISIBLE_STEP;
|
||||
|
||||
/**
|
||||
* 更早历史读取失败时的兜底文案。
|
||||
*
|
||||
* 宿主可能抛出空 message 的错误(裸 `new Error()`、宿主侧空串)。空串是 falsy,而
|
||||
* `historyError` 同时是「挂起自动加载」的闸门与内联错误行的显隐开关——空串会让两者一起
|
||||
* 消失,滚到顶就变成反复重试同一个失败读取。这里在源头收口成一句稳定文案。
|
||||
*/
|
||||
const DIRECT_HISTORY_LOAD_ERROR_FALLBACK = '读取更早的对话历史失败';
|
||||
|
||||
/**
|
||||
* 工作台壳注入的项目对话读权限门。返回 `false` 表示已入队确认:读取由 `onConfirmed`
|
||||
* 触发,壳自己负责取消时的状态文案。
|
||||
@@ -137,7 +146,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,10 +192,34 @@ export function useDirectProjectChatController({
|
||||
*/
|
||||
const pendingTurns = directThread.state.pendingTurns;
|
||||
const [historyHasMore, setHistoryHasMore] = useState(false);
|
||||
const historyOldestItemIdRef = useRef<string | null>(null);
|
||||
/**
|
||||
* 更早历史的加载态与失败态:渲染层要拿它决定加载行与内联错误行的显隐。
|
||||
*
|
||||
* `historyLoadingRef` 是同一件事的同步副本:并发判据必须在同一次事件处理里立刻生效,
|
||||
* 而 `setState` 要等下一次渲染,直接用 state 判会放过第二次触发。
|
||||
*/
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const historyLoadingRef = useRef(false);
|
||||
const historyErrorRef = useRef<string | null>(null);
|
||||
const historyOldestItemIdRef = useRef<string | null>(null);
|
||||
/**
|
||||
* 更早历史读取的世代号:新起一次读取、以及切换项目都推进一格。
|
||||
*
|
||||
* 只比项目路径不够——A→B→A 之后在飞的旧 A 读取又落回同一个路径,会把新一代的加载态与游标
|
||||
* 一起改掉。世代号让任何非最新一次读取在落地时整段失效(不合并、不写游标、不关加载态)。
|
||||
*
|
||||
* 换项目的推进放在**渲染期**,与 `projectPathRef` 同一处:守卫要在切换提交之后立刻生效。
|
||||
* 放进下面的复位 effect 会晚一步——React 的 passive effect 走宏任务,promise 续体走微任务,
|
||||
* 旧读取可能在「提交完成、复位 effect 还没跑」的窗口里落地,那时世代号还是旧的,于是照常合并
|
||||
* 条目、写游标、关加载态(复位 effect 随后会清掉状态,但这仍是一次白跑的跨项目读取)。
|
||||
*/
|
||||
const historyLoadTokenRef = useRef(0);
|
||||
const projectPathRef = useRef<string | null>(projectPath);
|
||||
projectPathRef.current = projectPath;
|
||||
if (projectPathRef.current !== projectPath) {
|
||||
historyLoadTokenRef.current += 1;
|
||||
projectPathRef.current = projectPath;
|
||||
}
|
||||
const directTurnRunningRef = useRef(currentTurnRunning);
|
||||
directTurnRunningRef.current = currentTurnRunning;
|
||||
const commandInFlightRef = useRef(commandInFlight);
|
||||
@@ -196,6 +230,10 @@ export function useDirectProjectChatController({
|
||||
setStatusNotice('');
|
||||
setLocalMessages([]);
|
||||
setHistoryHasMore(false);
|
||||
setHistoryLoading(false);
|
||||
historyLoadingRef.current = false;
|
||||
historyErrorRef.current = null;
|
||||
setHistoryError(null);
|
||||
historyOldestItemIdRef.current = null;
|
||||
}, [projectPath]);
|
||||
|
||||
@@ -649,18 +687,30 @@ 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;
|
||||
// 本次读取的世代号:切项目或另起一次读取都会把它推进,落地时过期就读作失效。
|
||||
const loadToken = (historyLoadTokenRef.current += 1);
|
||||
const isStaleLoad = () => historyLoadTokenRef.current !== loadToken;
|
||||
historyLoadingRef.current = true;
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const pages = await readDirectHistoryPages({
|
||||
existingEntries: directEntries,
|
||||
@@ -673,25 +723,40 @@ export function useDirectProjectChatController({
|
||||
: { limit: DIRECT_HISTORY_PAGE_SIZE }),
|
||||
}),
|
||||
});
|
||||
if (projectPathRef.current !== nextProjectPath) return;
|
||||
if (isStaleLoad()) return;
|
||||
directThread.mergeHistoryItems(pages.items);
|
||||
if (!pages.error) setHistoryHasMore(pages.hasMore);
|
||||
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)
|
||||
}`,
|
||||
);
|
||||
// 与加载成功路径同样按世代守卫:切项目后在飞的读取失败不能算到新项目头上。
|
||||
if (isStaleLoad()) return;
|
||||
const message =
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
DIRECT_HISTORY_LOAD_ERROR_FALLBACK;
|
||||
historyErrorRef.current = message;
|
||||
setHistoryError(message);
|
||||
} finally {
|
||||
historyLoadingRef.current = false;
|
||||
// 与成功 / 失败路径同样按世代守卫:旧世代读取落地时不能关掉当代的加载态——否则加载行
|
||||
// 提前消失,`historyLoadingRef` 这道并发闸门也会被重新打开,还能再并起第二次读取。
|
||||
// 只比路径不够:A→B→A 的旧读取又落回同一个路径,靠世代号才区分得开。
|
||||
if (!isStaleLoad()) {
|
||||
historyLoadingRef.current = false;
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动重试更早历史:先解挂起,再走同一条加载入口。 */
|
||||
function retryEarlierHistory() {
|
||||
historyErrorRef.current = null;
|
||||
setHistoryError(null);
|
||||
void loadEarlierHistory();
|
||||
}
|
||||
|
||||
return {
|
||||
appendLocalMessage,
|
||||
attachmentNotice,
|
||||
@@ -702,8 +767,11 @@ export function useDirectProjectChatController({
|
||||
directEntries,
|
||||
directTurnRunning: currentTurnRunning,
|
||||
directTurnStartedAt: currentTurnStartedAt,
|
||||
historyError,
|
||||
historyHasMore,
|
||||
historyLoading,
|
||||
loadEarlierHistory,
|
||||
retryEarlierHistory,
|
||||
commandInFlight,
|
||||
localMessages,
|
||||
pendingTurns,
|
||||
|
||||
@@ -356,9 +356,11 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
|
||||
bodyIndex,
|
||||
);
|
||||
|
||||
// 唯一滚动区仍是消息列表。
|
||||
// 唯一滚动区仍是消息列表;`[overflow-anchor:none]` 是滚动补偿契约的一部分
|
||||
// (见 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`),
|
||||
// 原生 scroll anchoring 一旦重新打开就会和会话侧补偿互相抵消。
|
||||
expect(conversationSource).toContain(
|
||||
'className="message-list project-chat-message-list"',
|
||||
'className="message-list project-chat-message-list [overflow-anchor:none]"',
|
||||
);
|
||||
|
||||
const formOpen = composerSource.indexOf('<form');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { act, createRef } from 'react';
|
||||
import { act } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { DirectProjectConversation } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation';
|
||||
@@ -25,12 +25,13 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以
|
||||
const view = render(
|
||||
<DirectProjectConversation
|
||||
turns={[]}
|
||||
messagesRef={createRef<HTMLDivElement>()}
|
||||
historyHasMore={false}
|
||||
historyLoading={false}
|
||||
historyError={null}
|
||||
turnInFlight
|
||||
activeTurnStartedAt={STARTED_AT}
|
||||
onLoadEarlierHistory={() => undefined}
|
||||
onScroll={() => undefined}
|
||||
onRetryEarlierHistory={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const elapsed = () => view.getByText(/^已耗时 /u).textContent;
|
||||
@@ -59,12 +60,13 @@ test('没有运行中的回合时不订阅时钟', () => {
|
||||
render(
|
||||
<DirectProjectConversation
|
||||
turns={[]}
|
||||
messagesRef={createRef<HTMLDivElement>()}
|
||||
historyHasMore={false}
|
||||
historyLoading={false}
|
||||
historyError={null}
|
||||
turnInFlight={false}
|
||||
activeTurnStartedAt={0}
|
||||
onLoadEarlierHistory={() => undefined}
|
||||
onScroll={() => undefined}
|
||||
onRetryEarlierHistory={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user