From 0aa9dad3c77d4287486b294ee6f7ce58e0fc9df2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?=
Date: Fri, 2 Oct 2026 17:54:03 +0800
Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9ADirectProject=20?=
=?UTF-8?q?=E5=AF=B9=E8=AF=9D=E8=87=AA=E5=8A=A8=E5=8A=A0=E8=BD=BD=E6=9B=B4?=
=?UTF-8?q?=E6=97=A9=E5=8E=86=E5=8F=B2=E5=B9=B6=E6=94=AF=E6=8C=81=E5=9B=9E?=
=?UTF-8?q?=E5=88=B0=E5=BA=95=E9=83=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 删掉常驻的「显示更早的对话」按钮,改由触顶与填充视口两条触发自动加载,失败即挂起并留内联重试行
- 新增 useConversationScroll:列表唯一滚动所有权(跟随最新、ResizeObserver 补偿、前插锚点冻结、胶囊显隐)
- 新增顶部加载行与底部居中 sticky 胶囊两个表现组件,文案与阈值取自 conversationScrollPolicy
- 列表追加 overflow-anchor:none 关掉原生锚定;「有新回复」按最后一轮终态内容指纹判定
- DirectProjectChatView 交出 messagesRef / shouldFollowLatestRef / handleScroll,只传加载状态与回调
Co-authored-by: Junie
---
.../chat/DirectProjectChatView.tsx | 30 +-
.../DirectProjectConversation.tsx | 66 +++--
.../DirectProjectHistoryRow.tsx | 54 ++++
.../DirectProjectScrollToBottomCapsule.tsx | 29 ++
.../conversationScrollPolicy.ts | 15 +
.../useConversationScroll.ts | 269 ++++++++++++++++++
6 files changed, 422 insertions(+), 41 deletions(-)
create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectHistoryRow.tsx
create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx
create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts
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..b914c8db9 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/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
index b9595d9ee..4aa29a5c2 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,40 @@
-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({
turns,
- messagesRef,
historyHasMore,
+ historyLoading,
+ historyError,
turnInFlight,
activeTurnStartedAt,
onLoadEarlierHistory,
- onScroll,
+ onRetryEarlierHistory,
}: {
turns: DirectChatTurn[];
- messagesRef: RefObject;
historyHasMore: boolean;
+ /** 更早历史正在读:驱动顶部加载行(延迟 150ms 才挂载)。 */
+ historyLoading: boolean;
+ /** 更早历史读取失败:非空即挂起自动加载,只留内联错误行的重试。 */
+ historyError: string | null;
/**
* 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。
*
@@ -33,28 +45,48 @@ export function DirectProjectConversation({
turnInFlight: boolean;
activeTurnStartedAt: number;
onLoadEarlierHistory: () => void;
- onScroll: UIEventHandler;
+ onRetryEarlierHistory: () => void;
}) {
+ const {
+ listRef,
+ onScroll,
+ onToggleCapture,
+ scrollToBottom,
+ showScrollToBottom,
+ scrollToBottomLabel,
+ showHistoryLoading,
+ } = useConversationScroll({
+ 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 (
+
+ {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/conversationScrollPolicy.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts
index 845aec2c7..af8d578c6 100644
--- 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
@@ -112,3 +112,18 @@ export function shouldFillViewport(
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/useConversationScroll.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts
new file mode 100644
index 000000000..cb3e2e7e8
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts
@@ -0,0 +1,269 @@
+import {
+ type MouseEventHandler,
+ type RefObject,
+ type UIEventHandler,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+
+import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
+import {
+ type ConversationTurnAnchor,
+ 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 = {
+ /** 已渲染的回合投影:内容变化既驱动贴底,也驱动「有新回复」。 */
+ 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({
+ turns,
+ historyHasMore,
+ historyLoading,
+ historyError,
+ turnInFlight,
+ onLoadEarlierHistory,
+}: ConversationScrollOptions): ConversationScrollBinding {
+ const listRef = useRef(null);
+ /** 跟随时任何高度变化都贴底;用户离开底部(或点胶囊)即翻转。 */
+ const followLatestRef = useRef(true);
+ 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));
+ /**
+ * 更早历史是否正在读:加载期间锚点保持冻结——加载行挂载、历史合并这些回调都还原同一个
+ * 锚点,加载结束后的下一次补偿才刷新(见 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;
+
+ 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;
+ }
+ // 锚点对应的块已经不在了(折叠态换过结构):拿当前位置重新起锚,别用过期偏移。
+ preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
+ }, []);
+
+ // 内容变化:跟随时贴底;不跟随时只在**终态内容**增加时点亮「有新回复」。
+ 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 = () => {
+ 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 started = turnInFlight && !turnInFlightRef.current;
+ turnInFlightRef.current = turnInFlight;
+ if (!started) return;
+ 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);
+ 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);
+ scrollListToBottom(listRef.current, 'smooth');
+ }, []);
+
+ return {
+ listRef,
+ onScroll,
+ onToggleCapture,
+ scrollToBottom,
+ showScrollToBottom: !atBottom,
+ scrollToBottomLabel: scrollToBottomLabel(hasNewReply),
+ showHistoryLoading,
+ };
+}