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

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-10-03 11:21:20 +08:00
27 changed files with 2816 additions and 68 deletions
@@ -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 &&
@@ -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)
}
@@ -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,
);
});
});
@@ -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
@@ -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>
);
}
@@ -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>
);
}
@@ -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}
@@ -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);
});
});
@@ -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;
}
@@ -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]),
);
});
});
@@ -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}`;
}
@@ -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);
});
});
@@ -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;
}
@@ -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,
};
}
@@ -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;
}
@@ -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 ?? ''}
@@ -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);
});
});
@@ -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();