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 b914c8db9..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 @@ -225,6 +225,7 @@ export function DirectProjectChatView({ onRequestGamePublish={onRequestGamePublish} /> ({ endedAt: 20, }); -function renderConversation(overrides: { +function conversationElement(overrides: { + conversationKey?: string; turns?: DirectChatTurn[]; historyHasMore?: boolean; historyLoading?: boolean; @@ -23,8 +24,9 @@ function renderConversation(overrides: { onLoadEarlierHistory?: () => void; onRetryEarlierHistory?: () => void; }) { - return render( + return ( 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(); }); @@ -90,3 +104,25 @@ describe('前插锚点的标记', () => { ).toBeGreaterThan(0); }); }); + +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 4aa29a5c2..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 @@ -20,6 +20,7 @@ import { useConversationScroll } from './useConversationScroll'; * 回合来自 DirectProject 自己的投影;这里不读历史、不发回合,只把容器给的状态渲染出来。 */ export function DirectProjectConversation({ + conversationKey, turns, historyHasMore, historyLoading, @@ -29,6 +30,8 @@ export function DirectProjectConversation({ onLoadEarlierHistory, onRetryEarlierHistory, }: { + /** 会话身份键(项目路径):切项目即复位滚动所有权,不靠重建列表。 */ + conversationKey: string; turns: DirectChatTurn[]; historyHasMore: boolean; /** 更早历史正在读:驱动顶部加载行(延迟 150ms 才挂载)。 */ @@ -56,6 +59,7 @@ export function DirectProjectConversation({ scrollToBottomLabel, showHistoryLoading, } = useConversationScroll({ + conversationKey, turns, historyHasMore, historyLoading, 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 index b64824c24..89b95285b 100644 --- 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 @@ -117,6 +117,7 @@ function stubSmoothScroll(): { targets: number[] } { } function Harness({ + conversationKey = '/projects/a', turns, historyHasMore, historyLoading, @@ -124,6 +125,7 @@ function Harness({ turnInFlight = false, onLoadEarlierHistory, }: { + conversationKey?: string; turns: DirectChatTurn[]; historyHasMore: boolean; historyLoading: boolean; @@ -132,6 +134,7 @@ function Harness({ onLoadEarlierHistory: () => void; }) { const scroll = useConversationScroll({ + conversationKey, turns, historyHasMore, historyLoading, @@ -460,3 +463,101 @@ describe('点「回到底部」后的程序化滚动', () => { 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 index a30acc1d2..bf8c55c03 100644 --- 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 @@ -41,6 +41,14 @@ import { useDelayedFlag } from './useDelayedFlag'; * 分散到视图里就会互相覆盖(同一帧里谁后写谁赢,表现为间歇性跳动)。 */ export type ConversationScrollOptions = { + /** + * 会话身份键(DirectProject 传项目路径)。 + * + * 列表容器在切项目时不会重挂载,滚动所有权却只在挂载时初始化一次:上一个会话的「已离开 + * 底部」会漏到新会话——首屏不贴底,新会话的第一批回合还会被误判成「有新回复」。身份一变 + * 就把这批状态复位并重新贴底,而不是靠重建组件(见 ADR 第 6 条)。 + */ + conversationKey: string; /** 已渲染的回合投影:内容变化既驱动贴底,也驱动「有新回复」。 */ turns: readonly DirectChatTurn[]; historyHasMore: boolean; @@ -76,6 +84,7 @@ function scrollListToBottom( } export function useConversationScroll({ + conversationKey, turns, historyHasMore, historyLoading, @@ -103,6 +112,8 @@ export function useConversationScroll({ const foldRef = useRef(null); const turnInFlightRef = useRef(turnInFlight); const terminalSignatureRef = useRef(terminalContentSignature(turns)); + /** 上一次的会话身份:变了就复位滚动所有权(见 `conversationKey`)。 */ + const conversationKeyRef = useRef(conversationKey); /** * 更早历史是否正在读:加载期间锚点保持冻结——加载行挂载、历史合并这些回调都还原同一个 * 锚点,加载结束后的下一次补偿才刷新(见 ADR 第 2 条),否则两次补偿会各按各的基准还原。 @@ -170,6 +181,22 @@ export function useConversationScroll({ 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);