diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
index e10829c5f..86a7b0ef3 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
@@ -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(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 = (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 (
void loadEarlierHistory()}
- onScroll={handleScroll}
+ onRetryEarlierHistory={() => void retryEarlierHistory()}
/>
{pendingConfirmation &&
onConfirmConfirmation &&
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx
index c3d7da397..7394c030a 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx
@@ -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 +
+ * 块身份」定位,不按块序号——回合收口时过程块会被折进 ``,序号会整体后移。
+ */
+ 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)
}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx
new file mode 100644
index 000000000..3dc7c91b2
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx
@@ -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` 对运行中的回合把过程块平铺、对已结束的回合把它们折进一个
+ * ``,所以「收口前后同一个块的块身份是否跟着块走」只能拿这种回合验。
+ */
+const processTurn = (
+ state: DirectChatTurn['state'],
+ overrides: Partial = {},
+): 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 (
+ 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')] }));
+
+ // 收口后过程被折进一个 ``,同一个工具组的序号从 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,
+ );
+ });
+});
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
index b9595d9ee..3009776b6 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
@@ -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;
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;
+ 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` 这一份,避免两套补偿在同一帧里互相抵消。 */}
- {historyHasMore ? (
-
+ {showHistoryLoading ? : null}
+ {historyError ? (
+
) : null}
{turns.map((turn) => (
))}
+ {showScrollToBottom ? (
+
+ ) : null}
{turnInFlight ? (
+
+ {HISTORY_LOADING_TEXT}
+
+ );
+}
+
+/** 自动加载挂起后的内联错误行:重试是唯一的手动出路,成功前一直留在列表顶部。 */
+export function DirectProjectHistoryErrorRow({
+ onRetry,
+}: {
+ onRetry: () => void;
+}) {
+ return (
+
+ {/* `role="alert"` 隐含 assertive + atomic:只有文案本身进 live region,重试按钮留在外面,
+ 否则整段会被当成一条断言性播报,可聚焦控件也被卷进 live region 内部。 */}
+ {HISTORY_ERROR_TEXT}
+
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx
new file mode 100644
index 000000000..cbf78edf9
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx
index 8ac6cb5f9..047e5cf0f 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx
@@ -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 + 块身份」定位,
+ * 不按块序号——收口时过程块被折进 ``,块序号会整体后移(见
+ * `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 (
-
+
执行过程
{blocks}
@@ -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 (
-
+
);
}
const role = block.kind === 'user' ? 'user' : 'assistant';
return (
-
+
+ ({
+ 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 });
+ // 被折进收起 `` 的块在真实浏览器里矩形全 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);
+ });
+});
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts
new file mode 100644
index 000000000..a16c65926
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts
@@ -0,0 +1,131 @@
+/**
+ * 更早历史前插时的视口锚点:读取与还原。
+ *
+ * 锚点粒度是「回合 key(`data-turn-key`)+ 块身份(`data-block-key`)+ 相对列表顶边的像素偏移」。
+ * 块身份就是 `DirectChatBlock.key`(条目块是 `${回合 key}:${条目 itemId}`,本地说明块是
+ * `messageId`),只要求在同一回合内唯一;「执行过程」包装块与终态文案各有一个回合内唯一的
+ * 字面量块身份(见 `DirectProjectTurn.tsx`)。
+ *
+ * 为什么不存块序号:`DirectProjectTurn` 对运行中的回合把过程块平铺、对已结束的回合把它们折进
+ * 一个 ``,收口时整个回合的块序号都会后移(`[用户, 工具组1, 工具组2]` →
+ * `[用户, 执行过程, 工具组1, 工具组2, 终态正文, 终态文案]`),序号锚点会解析到隔壁块。
+ * 合并更早历史只把新回合插在更早的位置,块身份跨前插稳定。还原只改 `scrollTop`。
+ *
+ * 块没有布局盒(被折进**收起**的 ``、或已不可见)时不参与:读锚点时跳过它,还原时
+ * 直接判失败(返回 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();
+
+/** 列表里所有带回合身份的块,按文档顺序;子元素没变时复用上一次的结果。 */
+export function collectTurnBlocks(list: HTMLElement): HTMLElement[] {
+ const cached = turnBlockCache.get(list);
+ if (cached) return cached;
+ const blocks = Array.from(
+ list.querySelectorAll(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,所以不是「每帧几百次强制布局」的振荡源。
+ // 真要省掉遍历只能按「块在文档顺序里位置单调递增」做二分、或从上次命中处续扫;但被折进**收起**
+ // `` 的块矩形全 0、同样在候选集合里,会打断这个单调性,二分前得先定「隐藏块怎么参与」,
+ // 而给锚点集合加「只收可见块」的规则又要每次子元素变化先量一遍,等于把省下的遍历又搬回来。
+ // 若真机在超长历史里量到滚动手感问题,先走「缓存上次命中的块身份 + 从它附近续扫」这条不破坏
+ // 单调性假设的路,而不是 rAF 节流(滚动事件本就按帧派发,同一帧去重收益极小)。
+ for (const block of collectTurnBlocks(list)) {
+ const rect = block.getBoundingClientRect();
+ // 折进收起 `` 的块在真实浏览器里没有布局盒(矩形全 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`,让同一个块回到原来的偏移。
+ *
+ * 返回是否还原成功:锚点对应的块已经不在列表里、或它已经被折进收起的 ``(矩形全 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;
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.test.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.test.ts
new file mode 100644
index 000000000..9351745da
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.test.ts
@@ -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]),
+ );
+ });
+});
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts
new file mode 100644
index 000000000..af8d578c6
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts
@@ -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}`;
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts
new file mode 100644
index 000000000..e75996750
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.test.ts
@@ -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;
+}
+
+/**
+ * 一块折叠区的两种形态:
+ * - ``(回合的「执行过程」);
+ * - `[aria-expanded]` 按钮 + `aria-controls` 正文(工具组头、「思考过程」)。
+ */
+function buildFixture() {
+ document.body.innerHTML = `
+
+
+ 执行过程
+ 过程正文
+
+
+
不是折叠头
+
`;
+ 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);
+ });
+});
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts
new file mode 100644
index 000000000..56924012f
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts
@@ -0,0 +1,116 @@
+/**
+ * 折叠块展开 / 收起后的视口处理。
+ *
+ * 展开一个 `` 或 `[aria-expanded]` 块不会改变 `scrollTop`,但会把正文长在视口下方:
+ * 用户必须再滚一次才能看到刚展开的内容。这里的做法是:
+ * 1. 头部仍在视口里 → 把它拉回展开前的屏幕位置(折叠头冻结);
+ * 2. 展开正文比视口还高、头部已经锚不住 → 对齐正文顶边,让用户从正文开头读起。
+ *
+ * 判断所需的"展开前位置"由调用方在点击捕获阶段记录,见 `useConversationScroll`。
+ */
+
+export type ConversationToggleFold = {
+ /** 触发折叠的头部元素:`` 的 `summary`,或带 `aria-expanded` 的按钮。 */
+ head: HTMLElement;
+ /** 头部在展开前相对视口顶边的位置(`getBoundingClientRect().top`)。 */
+ headTop: number;
+};
+
+/**
+ * 从点击目标向上找折叠头。
+ *
+ * 两种折叠头都要认:`DirectProjectTurn` 的「执行过程」是 ``,`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;
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx
new file mode 100644
index 000000000..620ec09f2
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx
@@ -0,0 +1,584 @@
+/** @vitest-environment jsdom */
+import { act, cleanup, fireEvent, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
+import { useConversationScroll } from './useConversationScroll';
+
+type ListMetrics = {
+ scrollTop: number;
+ scrollHeight: number;
+ clientHeight: number;
+};
+
+const PATCHED_GEOMETRY = [
+ 'scrollTop',
+ 'scrollHeight',
+ 'clientHeight',
+ 'getBoundingClientRect',
+ 'scrollTo',
+] as const;
+
+/**
+ * 假 ResizeObserver:记录回调,用例自己决定何时模拟一次布局变化。
+ *
+ * jsdom 没有 ResizeObserver,也不做布局,滚动补偿的唯一入口只能靠它驱动。
+ */
+class FakeResizeObserver {
+ private static callbacks: Array<() => void> = [];
+ private readonly callback: () => void;
+
+ constructor(callback: () => void) {
+ this.callback = callback;
+ FakeResizeObserver.callbacks.push(callback);
+ }
+
+ observe(): void {}
+
+ unobserve(): void {}
+
+ disconnect(): void {
+ FakeResizeObserver.callbacks = FakeResizeObserver.callbacks.filter(
+ (item) => item !== this.callback,
+ );
+ }
+
+ static reset(): void {
+ FakeResizeObserver.callbacks = [];
+ }
+
+ /** 模拟一次「列表子元素高度变了」:展开、图片撑高、历史前插都走这条路。 */
+ static triggerLayoutChange(): void {
+ act(() => {
+ for (const callback of [...FakeResizeObserver.callbacks]) callback();
+ });
+ }
+}
+
+/**
+ * jsdom 不做布局:几何量全部挂到 `HTMLDivElement.prototype` 上,用例自己给数字。
+ *
+ * 挂在原型而不是实例上,是因为列表元素由 React 在渲染时创建,挂载期读几何的效果必须
+ * 一开始就能读到这些值。`afterEach` 删掉这些自有属性即可落回 jsdom 的原实现。
+ */
+function stubListMetrics(metrics: ListMetrics): { writes: number } {
+ const counter = { writes: 0 };
+ Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', {
+ configurable: true,
+ get: () => metrics.scrollHeight,
+ });
+ Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', {
+ configurable: true,
+ get: () => metrics.clientHeight,
+ });
+ Object.defineProperty(HTMLDivElement.prototype, 'scrollTop', {
+ configurable: true,
+ get: () => metrics.scrollTop,
+ set: (value: number) => {
+ counter.writes += 1;
+ metrics.scrollTop = value;
+ },
+ });
+ Object.defineProperty(HTMLDivElement.prototype, 'getBoundingClientRect', {
+ configurable: true,
+ value: () =>
+ ({
+ top: 0,
+ bottom: metrics.clientHeight,
+ height: metrics.clientHeight,
+ left: 0,
+ right: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ }) as DOMRect,
+ });
+ return counter;
+}
+
+/**
+ * 平滑滚动在 jsdom 里没有实现:只记录目标位置。
+ *
+ * 真实浏览器里写一次 `scrollTop` 就会取消动画,而 jsdom 不会——所以「动画被打断」这件事
+ * 只能靠「有没有人写 `scrollTop`」来断言,不能靠位置收敛来断言。
+ */
+function stubSmoothScroll(): { targets: number[] } {
+ const calls = { targets: [] as number[] };
+ Object.defineProperty(HTMLDivElement.prototype, 'scrollTo', {
+ configurable: true,
+ value: (options?: ScrollToOptions | number) => {
+ calls.targets.push(
+ typeof options === 'number' ? options : (options?.top ?? 0),
+ );
+ },
+ });
+ return calls;
+}
+
+function Harness({
+ conversationKey = '/projects/a',
+ turns,
+ historyHasMore,
+ historyLoading,
+ historyError,
+ turnInFlight = false,
+ onLoadEarlierHistory,
+}: {
+ conversationKey?: string;
+ turns: DirectChatTurn[];
+ historyHasMore: boolean;
+ historyLoading: boolean;
+ historyError: string | null;
+ turnInFlight?: boolean;
+ onLoadEarlierHistory: () => void;
+}) {
+ const scroll = useConversationScroll({
+ conversationKey,
+ turns,
+ historyHasMore,
+ historyLoading,
+ historyError,
+ turnInFlight,
+ onLoadEarlierHistory,
+ });
+ return (
+ <>
+ {/* 一个回合块:前插锚点与「往哪儿补偿」都要有真实可定位的元素。 */}
+
+
+
+ {scroll.showScrollToBottom ? scroll.scrollToBottomLabel : ''}
+
+ {scroll.showHistoryLoading ? : null}
+ >
+ );
+}
+
+const turn = (
+ key: string,
+ overrides: Partial = {},
+): 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,
+ ...overrides,
+});
+
+afterEach(() => {
+ cleanup();
+ FakeResizeObserver.reset();
+ vi.unstubAllGlobals();
+ for (const key of PATCHED_GEOMETRY) {
+ Reflect.deleteProperty(HTMLDivElement.prototype, key);
+ }
+});
+
+describe('更早历史的自动加载', () => {
+ it('内容还没铺满视口:首帧之后继续加载,直到门关上', () => {
+ stubListMetrics({ scrollTop: 0, scrollHeight: 100, clientHeight: 400 });
+ const load = vi.fn();
+ render(
+ ,
+ );
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it('滚动触顶才加载:已经离顶就不再触发', () => {
+ const metrics: ListMetrics = {
+ scrollTop: 0,
+ scrollHeight: 1200,
+ clientHeight: 400,
+ };
+ stubListMetrics(metrics);
+ const load = vi.fn();
+ const view = render(
+ ,
+ );
+ // 内容比视口高,填充视口的判据不成立。
+ expect(load).not.toHaveBeenCalled();
+
+ metrics.scrollTop = 10;
+ fireEvent.scroll(view.getByTestId('list'));
+ expect(load).toHaveBeenCalledTimes(1);
+
+ metrics.scrollTop = 400;
+ fireEvent.scroll(view.getByTestId('list'));
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it('失败挂起后不再自动加载:只有手动重试能解开', () => {
+ const metrics: ListMetrics = {
+ scrollTop: 0,
+ scrollHeight: 1200,
+ clientHeight: 400,
+ };
+ stubListMetrics(metrics);
+ const load = vi.fn();
+ const view = render(
+ ,
+ );
+ metrics.scrollTop = 0;
+ fireEvent.scroll(view.getByTestId('list'));
+ expect(load).not.toHaveBeenCalled();
+ });
+});
+
+describe('回到底部胶囊', () => {
+ it('离开底部才出现,进入阈值即消失', () => {
+ const metrics: ListMetrics = {
+ scrollTop: 0,
+ scrollHeight: 1200,
+ clientHeight: 400,
+ };
+ stubListMetrics(metrics);
+ const view = render(
+ undefined}
+ />,
+ );
+ const capsule = () => view.getByTestId('capsule').textContent;
+ // 初始按"在底部"处理:首帧不该凭空冒出一颗按钮。
+ expect(capsule()).toBe('');
+
+ // 挂载时的贴底效果已经把 scrollTop 推到内容高度:先让用户真的往上滚。
+ metrics.scrollTop = 0;
+ fireEvent.scroll(view.getByTestId('list'));
+ expect(capsule()).toBe('回到底部');
+
+ metrics.scrollTop = 1200 - 400 - 10;
+ fireEvent.scroll(view.getByTestId('list'));
+ expect(capsule()).toBe('');
+ });
+
+ it('不跟随时来了新的终态内容:文案换成「有新回复 · 回到底部」', () => {
+ const metrics: ListMetrics = {
+ scrollTop: 0,
+ scrollHeight: 1200,
+ clientHeight: 400,
+ };
+ stubListMetrics(metrics);
+ const view = render(
+ undefined}
+ />,
+ );
+ const capsule = () => view.getByTestId('capsule').textContent;
+ // 挂载时的贴底效果已经把 scrollTop 推到内容高度:先让用户真的往上滚。
+ metrics.scrollTop = 0;
+ fireEvent.scroll(view.getByTestId('list'));
+ expect(capsule()).toBe('回到底部');
+
+ // 新一回合:用户气泡 + 助手终态都是新内容。
+ view.rerender(
+ undefined}
+ />,
+ );
+ expect(capsule()).toBe('有新回复 · 回到底部');
+ });
+
+ it('往前插更早历史不算新回复:只把用户往上顶,不点亮提示', () => {
+ const metrics: ListMetrics = {
+ scrollTop: 0,
+ scrollHeight: 1200,
+ clientHeight: 400,
+ };
+ stubListMetrics(metrics);
+ const view = render(
+ undefined}
+ />,
+ );
+ const capsule = () => view.getByTestId('capsule').textContent;
+ // 挂载时的贴底效果已经把 scrollTop 推到内容高度:先让用户真的往上滚。
+ metrics.scrollTop = 0;
+ fireEvent.scroll(view.getByTestId('list'));
+ expect(capsule()).toBe('回到底部');
+
+ view.rerender(
+ undefined}
+ />,
+ );
+ expect(capsule()).toBe('回到底部');
+ });
+});
+
+describe('加载行', () => {
+ it('延迟 150ms 才显示:本地瞬时读取不闪一下', () => {
+ vi.useFakeTimers();
+ try {
+ stubListMetrics({ scrollTop: 0, scrollHeight: 1200, clientHeight: 400 });
+ const view = render(
+ undefined}
+ />,
+ );
+ expect(view.queryByTestId('loading')).toBeNull();
+
+ act(() => {
+ vi.advanceTimersByTime(149);
+ });
+ expect(view.queryByTestId('loading')).toBeNull();
+
+ act(() => {
+ vi.advanceTimersByTime(2);
+ });
+ expect(view.queryByTestId('loading')).not.toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe('点「回到底部」后的程序化滚动', () => {
+ function renderLongList() {
+ const metrics: ListMetrics = {
+ scrollTop: 0,
+ scrollHeight: 1200,
+ clientHeight: 400,
+ };
+ const counter = stubListMetrics(metrics);
+ const smooth = stubSmoothScroll();
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver);
+ const view = render(
+ undefined}
+ />,
+ );
+ const list = view.getByTestId('list');
+ // 用户先往上滚,离开底部:胶囊出现,之后才点它。
+ metrics.scrollTop = 0;
+ fireEvent.scroll(list);
+ return { metrics, counter, smooth, view, list };
+ }
+
+ it('动画途中收到滚动事件不算用户意图:胶囊不闪回', () => {
+ const { metrics, smooth, view } = renderLongList();
+ fireEvent.click(view.getByTestId('to-bottom'));
+ expect(smooth.targets).toEqual([1200]);
+
+ // 平滑动画刚走了一帧、还没到贴底阈值。
+ metrics.scrollTop = 420;
+ fireEvent.scroll(view.getByTestId('list'));
+
+ expect(view.getByTestId('capsule').textContent).toBe('');
+ });
+
+ it('动画途中不写 scrollTop:写一次就把动画取消在半路', () => {
+ const { metrics, counter, view, list } = renderLongList();
+ fireEvent.click(view.getByTestId('to-bottom'));
+ const writesBefore = counter.writes;
+
+ metrics.scrollTop = 420;
+ fireEvent.scroll(list);
+ FakeResizeObserver.triggerLayoutChange();
+
+ expect(counter.writes).toBe(writesBefore);
+ });
+
+ it('动画途中内容变高:把动画目标重新对准新的底部', () => {
+ const { metrics, smooth, view } = renderLongList();
+ fireEvent.click(view.getByTestId('to-bottom'));
+ expect(smooth.targets).toEqual([1200]);
+
+ // 点下去之后内容又长高了(图片撑开 / 流式正文):点击瞬间记下的 1200 已经不是底部。
+ metrics.scrollHeight = 1500;
+ FakeResizeObserver.triggerLayoutChange();
+
+ // 修正前:程序化滚动期间补偿直接跳过,动画停在旧目标 1200 上;`programmaticScrollRef` 只在
+ // 「贴底」那次滚动事件里交还,于是跟随与补偿永久挂起,胶囊又已按「已贴底」隐掉。
+ expect(smooth.targets).toEqual([1200, 1500]);
+
+ // 动画落到新底部后照常交还控制权:再往上滚仍然能出现胶囊。
+ metrics.scrollTop = 1500 - 400;
+ fireEvent.scroll(view.getByTestId('list'));
+ metrics.scrollTop = 400;
+ fireEvent.scroll(view.getByTestId('list'));
+ expect(view.getByTestId('capsule').textContent).toBe('回到底部');
+ });
+
+ it('动画落到位后交还控制权:再往上滚照样显示胶囊', () => {
+ const { metrics, view, list } = renderLongList();
+ fireEvent.click(view.getByTestId('to-bottom'));
+
+ metrics.scrollTop = 1200 - 400;
+ fireEvent.scroll(list);
+ expect(view.getByTestId('capsule').textContent).toBe('');
+
+ metrics.scrollTop = 400;
+ fireEvent.scroll(list);
+ expect(view.getByTestId('capsule').textContent).toBe('回到底部');
+ });
+
+ it('用户中途用滚轮打断:立刻交还控制权,后续滚动照常判定', () => {
+ const { metrics, view, list } = renderLongList();
+ fireEvent.click(view.getByTestId('to-bottom'));
+
+ metrics.scrollTop = 420;
+ fireEvent.wheel(list);
+ fireEvent.scroll(list);
+
+ expect(view.getByTestId('capsule').textContent).toBe('回到底部');
+ });
+});
+
+describe('换会话时的滚动所有权', () => {
+ /** 在项目 A 里往上滚过:跟随最新关闭、胶囊出现,正是上一个会话漏过来的那批状态。 */
+ function scrollUpInProjectA() {
+ const metrics: ListMetrics = {
+ scrollTop: 0,
+ scrollHeight: 1200,
+ clientHeight: 400,
+ };
+ stubListMetrics(metrics);
+ const view = render(
+ undefined}
+ />,
+ );
+ metrics.scrollTop = 0;
+ fireEvent.scroll(view.getByTestId('list'));
+ expect(view.getByTestId('capsule').textContent).toBe('回到底部');
+ return { metrics, view };
+ }
+
+ function switchToProjectB(view: ReturnType) {
+ view.rerender(
+ undefined}
+ />,
+ );
+ }
+
+ it('新会话首屏重新贴底:不继承上一个会话的「已离开底部」', () => {
+ const { metrics, view } = scrollUpInProjectA();
+
+ switchToProjectB(view);
+
+ // 身份一变就复位并贴底:首屏不该还停在上一个会话滚出去的位置。
+ expect(metrics.scrollTop).toBe(1200);
+ expect(view.getByTestId('capsule').textContent).toBe('');
+ });
+
+ it('新会话的内容不算「有新回复」:往上滚只显示「回到底部」', () => {
+ const { metrics, view } = scrollUpInProjectA();
+
+ switchToProjectB(view);
+ metrics.scrollTop = 0;
+ fireEvent.scroll(view.getByTestId('list'));
+
+ expect(view.getByTestId('capsule').textContent).toBe('回到底部');
+ });
+
+ it('复位之后照常判定:新会话里不跟随时内容增加仍点亮「有新回复」', () => {
+ const metrics: ListMetrics = {
+ scrollTop: 0,
+ scrollHeight: 1200,
+ clientHeight: 400,
+ };
+ stubListMetrics(metrics);
+ const view = render(
+ undefined}
+ />,
+ );
+ metrics.scrollTop = 0;
+ fireEvent.scroll(view.getByTestId('list'));
+
+ switchToProjectB(view);
+ // 在 B 里重新往上滚:跟随关闭,之后内容再增加就该点亮「有新回复」。
+ metrics.scrollTop = 0;
+ fireEvent.scroll(view.getByTestId('list'));
+
+ view.rerender(
+ undefined}
+ />,
+ );
+
+ expect(view.getByTestId('capsule').textContent).toBe('有新回复 · 回到底部');
+ });
+});
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts
new file mode 100644
index 000000000..ca00bc8fc
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts
@@ -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;
+ onScroll: UIEventHandler;
+ /** 点击捕获:记录折叠头展开前的屏幕位置,供布局变化后的补偿用。 */
+ onToggleCapture: MouseEventHandler;
+ 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(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(null);
+ /** 刚刚展开 / 收起的折叠头;只被下一次布局补偿消费一次。 */
+ const foldRef = useRef(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;
+ }
+ // 锚点还原失败(块已经不在列表里,或收口后被折进收起的 ``、没有布局盒算不出偏移):
+ // 拿当前位置重新起锚,别用过期基准写 `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 = (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 = (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,
+ };
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts
new file mode 100644
index 000000000..41a5c6852
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts
@@ -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;
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/ToolCallGroup.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/ToolCallGroup.tsx
index 4ae13d4cd..41aa1bac0 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/ToolCallGroup.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/ToolCallGroup.tsx
@@ -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 +
+ * 块身份」定位,不按块序号——回合收口时过程块会被折进 ``,序号会整体后移。
+ */
+ 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 ?? ''}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx
new file mode 100644
index 000000000..9fb665092
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx
@@ -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) => {
+ if (command !== 'read_direct_project_history_slice') return undefined;
+ return new Promise((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 {
+ 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);
+ });
+});
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts
index 8f97e9b3e..6a4655995 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts
@@ -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(null);
+ /**
+ * 更早历史的加载态与失败态:渲染层要拿它决定加载行与内联错误行的显隐。
+ *
+ * `historyLoadingRef` 是同一件事的同步副本:并发判据必须在同一次事件处理里立刻生效,
+ * 而 `setState` 要等下一次渲染,直接用 state 判会放过第二次触发。
+ */
+ const [historyLoading, setHistoryLoading] = useState(false);
+ const [historyError, setHistoryError] = useState(null);
const historyLoadingRef = useRef(false);
+ const historyErrorRef = useRef(null);
+ const historyOldestItemIdRef = useRef(null);
+ /**
+ * 更早历史读取的世代号:新起一次读取、以及切换项目都推进一格。
+ *
+ * 只比项目路径不够——A→B→A 之后在飞的旧 A 读取又落回同一个路径,会把新一代的加载态与游标
+ * 一起改掉。世代号让任何非最新一次读取在落地时整段失效(不合并、不写游标、不关加载态)。
+ *
+ * 换项目的推进放在**渲染期**,与 `projectPathRef` 同一处:守卫要在切换提交之后立刻生效。
+ * 放进下面的复位 effect 会晚一步——React 的 passive effect 走宏任务,promise 续体走微任务,
+ * 旧读取可能在「提交完成、复位 effect 还没跑」的窗口里落地,那时世代号还是旧的,于是照常合并
+ * 条目、写游标、关加载态(复位 effect 随后会清掉状态,但这仍是一次白跑的跨项目读取)。
+ */
+ const historyLoadTokenRef = useRef(0);
const projectPathRef = useRef(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,
diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
index 20a1ff525..bcb9b3135 100644
--- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
+++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
@@ -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('