Merge pull request 'style/优化聊天历史页' (#590) from feat/smoother-dialog-history into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m55s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m12s
Project CI / Backend tests (push) Successful in 4m51s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m11s
Project CI / Frontend tests (push) Successful in 2m22s
Project CI / Native shell tests (push) Successful in 5m36s
Project CI / AI game creator shell web tests (push) Successful in 2m34s
Project CI / Repository checks (push) Successful in 3m3s

Reviewed-on: #590
This commit was merged in pull request #590.
This commit is contained in:
2026-10-02 23:22:32 +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();
+1
View File
@@ -58,6 +58,7 @@
- [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。
- [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。
- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`error` 字段就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,无字段变体用固定文案、带载荷分支先 `as` 取自己的具名载荷类型(可枚举细分再 `switch (payload.reason)` 在类型化枚举上分流),系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。
- [DirectProject 对话滚动与历史自动加载](./adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md):删掉「显示更早的对话」按钮改为自动加载 + 内联加载/错误行,回合 key 冻结锚点前插不跳,底部居中「回到底部 / 有新回复」胶囊,折叠展开按跟随状态贴底或保锚点。
- [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。
- [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
@@ -35,7 +35,7 @@ AGC 项目开发聊天框当前同时从多处取数据:Direct 回合事件(
- 执行通道断开同样是失败终态,也必须带 `failure`:连接级故障(app-server 进程退出 / stdout 流断 / JSON 行越界)与回合事件通道关闭都算,`kind="transport-failed"`、`message` 用宿主当场写下的那份诊断(含 `exitStatus` 与 stderr 摘要,已脱敏截断)。宿主在检测到连接终止的第一时间把这条事实记到本回合的执行适配器上,终态判定再从适配器读:执行适配器的看门狗盯着同一个 `closed` 标志,用调用点局部变量会输给这场调度竞争,失败原因就只剩日志、界面只会看到"本轮已结束"。判据是"适配器是否已由宿主主动关闭"——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)走的是同一个 `TransportClosed` 事件,但这些不算失败。
- 终态由**事实**判定,不由收尾阶段反推:判定按优先级取「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段的账本读不出来时才用交付报告」,**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾会把 ledger 阶段推成 `Interrupted`,让阶段决定终态就会把已经失败的一轮讲成"已结束"。模型自报失败(原生 `turn/completed.status="failed"` 的 `error`,带 `codexErrorInfo` 分类)不为载荷新增输入字段:宿主把原生 `error` 的 `codexErrorInfo` 解析成 typed 分类后当作本回合的错误结果,走同一条通道进载荷;交付报告只说明"收束到哪一步",不得顶掉原因。
- 回合失败在宿主内部是 **typed** 的:`agent/direct_turn_error.rs` 的 `DirectTurnError` 每个变体自带字段(并发拒绝带两个 invocation id、模型失败带分类、超时带撞的是哪条上限、通道断开带宿主诊断),**调用级拒绝**(这一轮没有开始)与**回合级失败**(这一轮已开始并被判失败)不共用判据,分流只认 `is_turn_failure()`。判据不再对原因文本做子串匹配,`LlmError` 只在平台层入口出现一次(`DirectTurnError::from_model_call`)。线上载荷 `{kind, message}`、命令边界字符串与 CLI 返回值都由这一个出口投影出来,Rust 侧任何地方都不再解析它们。
- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。
- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。(**按 2026-10-02 ADR 补充**:更早历史的触发时机、加载指示、失败重试与视口锚定改由 [`【ADR】DirectProject对话滚动与历史自动加载-2026-10-02`](./【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md) 规定;本条的分页锚点口径与连拉上限仍然有效。)
- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。
- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。
- 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。
@@ -0,0 +1,85 @@
# 【ADR】DirectProject对话滚动与历史自动加载-2026-10-02
状态:已接受
## 背景
DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/`)当前的滚动与历史体验有三个问题:
1. 更早历史要靠常驻按钮「显示更早的对话」(`.message-history-more`)手动拉,但 `handleScroll` 里已经有一条「滚到顶部 24px 以内就自动加载」的路径——按钮是半冗余的第二入口,还没有任何加载中的视觉反馈(`historyLoadingRef` 是 ref,渲染不出来)。
2. 用户滚上去之后没有任何「回到最新」的入口,只能自己一直滚到底。
3. 展开一折叠块(`<details>` 或 `[aria-expanded]`)时浏览器保持 `scrollTop` 不变,新展开的正文长在视口下方,用户必须再滚一次才能看到;反过来在底部展开时内容直接顶出可视区。
同一目录下的 `PlanningChatView` 复用 `.project-chat-message-list` / `.project-chat-conversation` 两个类名,所以任何滚动容器结构改动都必须留在 DirectProject 本地,不能改公共类。
## 决策
### 1. 更早历史:删掉按钮,自动加载 + 内联加载行
- 删除「显示更早的对话」按钮与 `DirectProjectConversation` 里的条件渲染;`.message-history-more` 样式**保留**——策划对话(`PlanningChatView`)仍在用同一套类名与按钮,等它一起改造时再删(见「影响」的后续项)。
- 触发一(滚动):`scrollTop <= 24` 且 `historyHasMore` 且不在加载中且没有失败记录时自动加载。
- 触发二(填充视口):首帧之后内容填不满视口(`scrollHeight <= clientHeight`)时继续加载,直到填满或 `hasMore=false`;不允许出现「历史比视口短、又没有按钮」的死局。
- 两个触发都不越过既有的首屏订阅锚点 `lastCompletedItemId`;一次加载仍最多连拉 5 页(口径见 [`【ADR】DirectProject对话历史单一事实源-2026-09-16`](./【ADR】DirectProject对话历史单一事实源-2026-09-16.md))。
- 加载中在列表最上方(比最旧一条回合更靠上)挂载一行 `role="status"`、`aria-live="polite"` 的「正在加载更早的对话」,带旋转圈;延迟 150ms 才显示,加载结束即卸载。它按需挂载,靠位置补偿(见第 2 条)保证下面的消息不跳。
- 失败:挂起自动加载,列表顶部保留一行内联错误行——`role="alert"` 只包住「加载更早对话失败」文案本身,重试是可聚焦按钮、留在 live region 之外(assertive + atomic 的 live region 里不放交互控件);**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。
- 一次加载与它所属的**世代**绑定:切换项目或新起一次读取都推进世代号(`historyLoadTokenRef`),旧世代落地时整段失效——不合并条目、不写游标、不关加载态。只比项目路径不够:A→B→A 之后在飞的旧读取又落回同一个路径,原守卫放行,会把新一代的加载行与 `historyLoadingRef` 这道并发闸门一起改掉。换项目的推进放在**渲染期**(与 `projectPathRef` 同一处),不放在复位 effect 里:passive effect 走宏任务、promise 续体走微任务,旧读取可能在「切换提交完成、复位 effect 还没跑」的窗口里落地,那时世代号还是旧的,守卫会放行。
### 2. 前插锚定:回合 key + 块身份 + 偏移,关掉浏览器原生锚定
- 锚点 = 顶部可见块的 `data-turn-key` + `data-block-key` + 相对列表顶边的像素偏移。
- 合并更早历史前记录锚点,合并后按同一 key/块身份把偏移还原,较早内容出现在上方而用户正在读的位置不动。
- 块身份来自 `DirectChatBlock.key`(条目块是 `${回合 key}:${条目 itemId}`,本地说明块是 `messageId`),只要求**在同一回合内唯一**。
- 不按块序号定位:`DirectProjectTurn` 的 `renderTurnProcess` 对运行中的回合把过程块**平铺**、对已结束的回合把它们折进一个 `<details>`,收口时整个回合的块序号都会后移(`[用户, 工具组1, 工具组2]` → `[用户, 执行过程, 工具组1, 工具组2, 终态正文, 终态文案]`),序号锚点会解析到隔壁块并按错误基准写 `scrollTop`,表现为一帧跳动。
- 锚点块没有布局盒时(被折进**收起**的 `<details>`、或已从列表移除)`restoreTurnAnchor` 返回 false:调用方据此放弃这次补偿,拿当前可见位置重新起锚,不写 `scrollTop`(不回跳,也不假装对齐)。同理,读锚点时跳过没有布局盒的块——它既不是「用户正在读的位置」,也还原不出来。
- 列表上加 `overflow-anchor: none`,关掉浏览器原生 scroll anchoring,保证只有一套补偿在跑(不做「两套补偿交替生效」)。
- 一次加载期间(`historyLoading` 为真)所有补偿——ResizeObserver 回调、加载行挂载、历史合并——都还原同一个冻结锚点;加载结束再刷新锚点。
- 块集合(列表里所有同时带 `data-turn-key` 与 `data-block-key` 的节点,即所有可锚定的块)在列表子元素没变时复用同一份缓存,缓存由子元素增删(与 `ResizeObserver` 重订阅共用同一个 `MutationObserver`)失效:否则一次滚动帧里读锚点、还原锚点会各查一遍整份列表,长会话下就是 O(块数) 的滚动抖动。
### 3. 回到底部胶囊
- 列表底部居中的悬浮胶囊(`sticky`),跟着滚动容器走、不随内容滚走。
- 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。
- 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。
- 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。
- 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。
### 4. 跟随最新与折叠展开
- 距底部 ≤ 48px 视为「在底部」,即跟随最新;用户手动滚回去(进入阈值)就自动恢复跟随,不需要额外开关。
- 用 `ResizeObserver` 观察列表的直接子元素(`MutationObserver` 负责在子元素变化时重订阅)——任何高度变化:
- 跟随时 → 贴底;
- 不跟随时 → 还原顶部可见回合锚点(用户正在读的那条折叠头留在原位);若刚刚展开的那个块头部仍然可见,则按展开前记录的屏幕位置把它冻结,并把新展开的正文露出来:正文底边超出视口就只滚到刚好露出底边,正文比视口还高则对齐正文顶部;
- 收起(正文高度归零)不额外滚动,只冻结头部。
- 新一回合开始(`turnInFlight` 由假转真)时强制恢复跟随并贴底,替代原先由视图直接写 `shouldFollowLatestRef` 的做法。
### 5. 结构与样式
- 滚动所有权(列表 ref、跟随最新、ResizeObserver、锚点还原、胶囊可见性)搬进 `DirectProjectConversation` 这一层;`DirectProjectChatView` 只传加载状态与回调,不再持有 `messagesRef` / `shouldFollowLatestRef` / `handleScroll`。
- 控制器对渲染层暴露可渲染的 `historyLoading` 与 `historyError`(外加 `retryEarlierHistory`),取代只存在于 ref 里的加载标志。
- 新增元素全部用内联 Tailwind 工具类,不改 `styles.css`;列表本身仍保持 `message-list project-chat-message-list` 类名不变,只追加 `overflow-anchor` 工具类。
### 6. 换会话时复位滚动所有权
- 列表容器在切项目时**不会重挂载**:`DirectProjectChatView` 在 `App.tsx` 只有一处渲染、没有 `key`,内部的 `DirectProjectConversation` 也没有 `key`,而 `useConversationScroll` 的跟随最新 / `atBottom` / `hasNewReply` / 前插锚点 / 折叠头 / 程序化滚动标记只在挂载时初始化一次。控制器自己按项目重置了历史状态,滚动所有权却一直漏着。
- 症状:在项目 A 往上滚过再切到 B——① B 的首屏不贴底,用户得自己往下滚;② B 的第一批回合会在「不跟随」分支被算成新内容,胶囊在新项目上直接显示「有新回复 · 回到底部」,可用户根本没在 B 里离开过底部。
- 决策:把会话身份(`conversationKey`,DirectProject 传项目路径)作为**显式信号**传给 `useConversationScroll`;身份变化时复位上述滚动所有权状态、把终态指纹同步成新会话内容、再贴底。不改变组件生命周期,同一个项目重开(身份不变)也不会被当成新会话;将来策划对话复用同一个 hook 时用的是同一套信号。
## 备选方案与取舍
1. **保留按钮 + 只加自动加载**:加载中仍靠按钮做唯一反馈,且删掉按钮后失败路径没有补救入口;按钮本身与滚动自动加载重复。
2. **在列表外面套一层 viewport 做浮层定位**:`PlanningChatView` 共用同一套容器规则,且工作台里 `.project-chat-conversation` 是 `display: block` + `height: 100%` 几何,套一层就会让 `height: 100%` 的列表塌成内容高度;改公共类会连带策划对话。改用列表内的 `sticky` 胶囊,零结构改动。
3. **只依赖原生 CSS scroll anchoring**:前插能免费对齐,但做不到「跟随时展开要贴底」,也无法在加载期间冻结同一套锚点;因此显式补偿 + 关闭原生锚定。
4. **展开后总是把正文滚进视口**:对正文比视口矮的折叠块会把画面大幅上移,打断正在读历史的用户;采用「跟随时贴底 / 否则冻结折叠头 + 只滚到刚好露出新展开正文的最小位移」。
5. **平滑滚动期间照常处理滚动事件与布局补偿**:程序化滚动会被应用自己的补偿打断(第一次写 `scrollTop` 即取消动画),用户点了「回到底部」也停在半路;因此改为滚动期间冻结这两条路径。
6. **自动重试失败的历史加载**:弱网下会反复打接口,且用户看不出到底在重试还是在挂起;改为挂起 + 内联重试行。
7. **用 `key={projectPath}` 让 React 重建会话列表**:改动最小,但整份消息列表连同加载行的 150ms 延迟计时一起重建,而且「同一个项目重开」也会被当成新会话;改用显式身份信号复位,重建与否与「换没换会话」解耦。
8. **锚点存块序号(`index`)而不是块身份**:序号在「回合 running→finished」时会因过程被折进 `<details>` 而整体后移,解析到隔壁块;改成稳定块身份只多一层 `data-block-key`,且块不可见仍要单独判失败,所以「失效锚点」省下的那点改动不值得留下错位。
9. **锚点块被折进收起的 `<details>` 时把还原改成找同回合内其它可见块**:会按另一条消息的基准写 `scrollTop`,比放弃这次补偿更糟;放弃只表现为不自动对齐,不回跳。
## 影响
- 历史加载失败不再只写顶部状态行,而是落到列表里的内联错误行;顶部状态行仍保留首屏读取失败等其它用途。
- 滚动是表现层行为,正式状态仍在后端投影与运行态事件;本 ADR 不新增领域概念。
- 阈值(触顶 24px、贴底 48px、spinner 150ms)是可按手感调整的常量,集中放在 `components/DirectProjectConversation/conversationScrollPolicy.ts`。
- 验收:纯函数与 jsdom 组件测试覆盖阈值、锚点还原(含收口后按块身份仍指向同一块、锚点块被折叠隐藏时放弃还原)、加载/错误行、胶囊文案与显隐;滚动观感(顶部加载圈、胶囊出现与消失、底部展开回贴、历史前插不跳、长回合收口时视口不跳、切项目后首屏贴底且不误报「有新回复」)必须真机手动验收——jsdom 没有布局。
- 明确的后续项(不在本次范围):`PlanningChatView` 与 `App.tsx` 遗留 `message-history-more` 路径的同款改造、未读条数徽标、Playwright 端到端。
@@ -1,4 +1,26 @@
# 决策记录
## 2026-10-02 DirectProject 对话:更早历史自动加载、前插锚定与回到底部胶囊
- 背景:右侧对话更早历史只靠常驻按钮「显示更早的对话」拉,且没有任何加载反馈(`historyLoadingRef` 是 ref,渲染不出来);用户滚上去之后没有「回到最新」的入口;展开「执行过程」/工具组/思考块时浏览器保持 `scrollTop`,新展开的正文长在视口下方,在底部展开更是直接顶出可视区。
- 决策:删按钮改自动加载——触顶 24px 与「首帧后内容填不满视口」共用一道门(有更早历史 ∧ 不在加载中 ∧ 无失败记录),失败挂起、只留内联「加载更早对话失败 · 重试」且不自动重试;加载行挂载在列表最上方、延迟 150ms 才显示。前插按「回合 key(`data-turn-key`)+ 块内序号 + 相对列表顶边偏移」冻结锚点,列表显式 `overflow-anchor: none`;加载期间所有补偿还原同一个冻结锚点,加载结束后的下一次补偿再刷新。底部居中 `sticky` 胶囊「回到底部」(不跟随时来了新终态内容改「有新回复 · 回到底部」):距底 48px 阈值、点击平滑滚动并恢复跟随。`ResizeObserver` 观察列表直接子元素(`MutationObserver` 负责子元素变化时重订阅):跟随时任何高度变化贴底,否则冻结刚展开的折叠头,头部锚不住再对齐展开正文顶边;新一回合开始(`turnInFlight` 假转真)强制恢复跟随。滚动所有权(列表 ref、跟随最新、补偿、胶囊显隐)从 `DirectProjectChatView` 搬进 `DirectProjectConversation` 及其同目录 hook,控制器新增可渲染的 `historyLoading` / `historyError` 与 `retryEarlierHistory`。
- 边界:只做 DirectProject;`PlanningChatView` 与 `App.tsx` 里的 `message-history-more` 遗留路径不动,因此该样式保留(后续项已写进 ADR)。新增元素全部用内联 Tailwind,`styles.css` 未改;阈值、文案与判据集中在同目录 `conversationScrollPolicy.ts`,契约见 [`【ADR】DirectProject对话滚动与历史自动加载-2026-10-02`](../../adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md),旧「单一事实源」ADR 的分页条目已改为引用它。
- 验证:新增 37 条与实现同目录的用例(阈值与加载门、锚点读取/还原、折叠头冻结与正文对齐、hook 的触顶/填充/胶囊/未读/延迟加载行、组件层的按钮移除与内联重试行)由根 `vitest.config.ts` 的 include 收进门禁;`npm run ai-game-creator-shell:typecheck`、`eslint`(含新文件)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过;滚动观感(顶部加载圈、胶囊显隐、底部展开回贴、历史前插不跳)留真机手动验收。
- 追加(2026-10-02,两个真机缺陷的根因与修正):①「点回到底部只下去一屏、到不了底」——平滑动画期间滚动事件把「跟随最新」翻成假,布局补偿与锚点还原接着写 `scrollTop`,而真实浏览器里任何一次写都会取消正在跑的平滑动画;现在滚动位置由这次程序化滚动独占(滚动事件只在贴底时才结算并交还,补偿整段跳过),滚轮 / 触摸 / 键盘接手立刻交还,避免标记永远挂着。②「展开折叠块没有自动滚动到位」——旧规则只在展开正文比视口还高时才动,正文矮的折叠块(工具组、思考块)展开后正文仍在视口外;现在冻结折叠头之后统一补「刚好露出新展开正文」的最小位移(底边超出就补超出量,比视口还高则对齐正文顶边),两段位移合成一次写。落在 `conversationToggleReveal.ts`(新增纯函数 `toggleRevealDelta`)与 `useConversationScroll.ts`(新增 `programmaticScrollRef`)。
- 验证(2026-10-02 追加):同目录用例补齐到 48 条(新增 `toggleRevealDelta` 六例、hook 的四例程序化滚动用例,其中两例在修正前确实红)全部通过;Chromium 真机脚本复验三处——点回到底部收敛到 `scrollHeight - clientHeight`、底部展开贴到新底、视口外展开补 269px 后正文底边正好贴视口下缘。
- 追加(2026-10-02,换会话复位滚动所有权):`useConversationScroll` 的跟随最新 / `atBottom` / `hasNewReply` / 前插锚点 / 折叠头 / 程序化滚动标记只在挂载时初始化一次,而 `DirectProjectChatView` 切项目时不重挂载——在项目 A 往上滚过再切 B,B 首屏不贴底且胶囊直接显示「有新回复 · 回到底部」。现在把会话身份(`conversationKey`,DirectProject 传项目路径)作为显式信号传进 hook,身份变化即复位这批状态、同步终态指纹并重新贴底;不改成 `key` 重建列表,避免消息列表重建与加载行 150ms 延迟计时重来,同一个项目重开也不算换会话。
- 验证(2026-10-02 追加,换会话复位):`useConversationScroll` 新增两例换会话用例(新会话首屏贴底且不出现胶囊、切会话后在 B 里往上滚只显示「回到底部」而不是「有新回复」),修正前确实红、修正后绿;chat 范围用例全绿。
- 追加(2026-10-02,review 两处内部缺陷的修正):①更早历史读取的守卫从「项目路径相等」改成「世代号相等」(`historyLoadTokenRef`,切项目与每次读取各推进一格)——A→B→A 之后在飞的旧读取又落回同一路径,原守卫会放行,把新一代的加载态、并发闸门与游标一起改掉;现在非最新一次读取落地时整段丢弃。②锚点的块集合按列表缓存(`WeakMap`),由已有的子元素 `MutationObserver` 在同一处 `invalidateTurnBlocks` 失效——原来一个滚动帧里读锚点、还原锚点各查一遍整份列表,长会话下是 O(块数) 的 DOM 查询。
- 验证(2026-10-02 追加,review 修正):新增一例控制器用例(驱动 A→B→A 且同路径新读取在飞,修正前在「旧读取落地」处红)与两例锚点缓存用例(连续收集只查一次 DOM、子元素变化并失效后重新收集);chat 范围用例全绿,`npm run ai-game-creator-shell:typecheck`、`eslint --max-warnings 0`、`npm run check:encoding`、`git diff --check` 通过。
- 追加(2026-10-02,锚点改用稳定块身份):review 第 3 条(`findTurnBlock` 按块序号定位)成立,采纳「换一套块身份」。锚点从 `{回合 key, 块序号, 偏移}` 改为 `{回合 key, 块身份, 偏移}`,块身份即 `DirectChatBlock.key`(`${回合 key}:${条目 itemId}` / 本地说明 `messageId`),只要求同一回合内唯一;展示层给每个可锚定块加 `data-block-key`(`DirectProjectTurn` 的正文块与终态文案、折进 `<details>` 的过程包装块、`ToolCallGroup`、`AgentReasoning` 各自透传),`conversationScrollAnchor.ts` 的收集选择器因此收敛为 `[data-turn-key][data-block-key]`。原因:`renderTurnProcess` 对运行中的回合平铺过程块、对已结束的回合折进一个 `<details>`,收口时整个回合的块序号后移一格,序号锚点会解析到隔壁块并按错误基准写 `scrollTop`。同时补上 review 指出、原方案漏掉的一半:锚点块没有布局盒(被折进收起的 `<details>`)时读锚点跳过它、`restoreTurnAnchor` 判为失败,调用方放弃这次补偿并重新起锚——不回跳,也不按别的块硬对齐。
- 验证(2026-10-02 追加,稳定块身份):锚点纯函数用例改为按块身份构造(`buildList` 传 `[回合 key, 块身份]`),新增「回合收口后块序号整体后移,块身份仍指向同一块且还原成功」「收起的 `<details>` 里的块没有布局盒:读锚点跳过、还原返回 false 且不写 `scrollTop`」;组件层新增用例断言每个带 `data-turn-key` 的块都有 `data-block-key`、同回合内块身份唯一、回合 running→finished 后同一块身份仍在。修正前锚点用例在「收口后按身份定位」处红。
- 追加(2026-10-02,review:动画期间内容变高会把程序化滚动标记卡住):`scrollToBottom` 把点击那一刻的 `scrollHeight` 当动画目标,而流式正文 / 图片撑开会让它在动画期间继续变高;`programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是动画停在旧目标后标记永远为真——布局补偿整段被跳过(列表不再跟随新内容),胶囊又已按「已贴底」隐掉,用户停在底部之上却没有任何指示和自动跟随。修正:程序化滚动期间布局补偿不写 `scrollTop`,但内容变高时把动画目标重新对准新的底部(`scrollListToBottom(list, 'smooth')`),动画继续跑到真正的底,标记照常在贴底时交还。
- 验证(2026-10-02 追加,动画目标重对准):`useConversationScroll.test.tsx` 新增一例——点击回到底部后内容变高(`scrollHeight` 1200→1500)触发一次布局变化,断言动画目标从 `[1200]` 变为 `[1200, 1500]`、落到新底部后仍能交出控制权;修正前在目标数组处红。
- 追加(2026-10-02,review:错误行的重试按钮在断言式 live region 内):`DirectProjectHistoryErrorRow` 原来把重试按钮渲染在 `<p role="alert">` 里面,而 `role="alert"` 隐含 `aria-live="assertive"` + `aria-atomic="true"`,交互控件会被卷进整段断言性播报、读屏也不一定把它当可聚焦按钮。修正:容器改为普通 `<div>`,`role="alert"` 只包住「加载更早对话失败」文案本身,重试按钮是 live region 之外的兄弟。
- 验证(2026-10-02 追加,错误行结构):`DirectProjectConversation.test.tsx` 新增一例断言 `role="alert"` 节点不包含重试按钮、且文案仍在 alert 内并仍可点击;修正前红。
- 追加(2026-10-02,review:世代号推进时机):`historyLoadTokenRef` 的换项目推进原来只在复位 effect 里,而 `projectPathRef.current` 是渲染期赋值的——交接窗口里守卫不再即时生效:React 的 passive effect 走宏任务、promise 续体走微任务,切换提交之后、复位 effect 之前落地的旧读取拿到的仍是旧世代号,会照常合并条目、写游标、关加载态(复位 effect 随后清掉状态,故终态没坏,但白跑一次跨项目读取且留下瞬时脏状态)。修正:推进移到渲染期,与 `projectPathRef` 同一处、只在路径真的变化时推进;复位 effect 不再推进。安全性依据:同一提交里子组件「填充视口」effect 的判据(`turns` / `historyHasMore` / `historyLoading` / `historyError` / 稳定的 `loadEarlier`)都不变,订阅状态复位本身也是 effect,因此这个窗口里不可能新起一次带旧游标的读取,去掉 effect 里的那一次推进不会放过它。
- 验证(2026-10-02 追加,世代号推进时机):该窗口依赖 React 调度(act 会把 effect 与断言放在同一个作用域里冲掉),jsdom 下无法构造出「旧读取先于复位 effect 落地」的确定性用例,因此没有新增红灯用例;既有的控制器世代用例(切项目、A→B→A)保持全绿,行为等价性由「本窗口内不会有新读取启动」的依赖分析支撑。真机若要硬证据,需在慢读取期间切项目并观察是否多打一次跨项目读取。
## 2026-10-01 Web、后台与 AGC 一键联调
- 背景:Web、管理后台和 AGC 同时开发时,分别启动入口容易产生两套 API/worker/SpacetimeDB,以及重复后台 Vite。

Some files were not shown because too many files have changed in this diff Show More