修复:切项目时复位 DirectProject 对话滚动所有权

useConversationScroll 新增 conversationKey 身份信号,身份变化时复位跟随最新 / 贴底 / 有新回复 / 前插锚点 / 折叠头 / 程序化滚动标记

复位时把终态指纹同步成新会话内容并重新贴底,避免同一次提交里的内容变化被算成「有新回复」

DirectProjectConversation 透传身份键,DirectProjectChatView 传项目路径,不重建列表 DOM

新增 3 条换会话用例与 1 条组件用例,组件用例抽成 conversationElement 以便 rerender

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 19:13:07 +08:00
parent 95a32a316f
commit ccc9b776d1
5 changed files with 172 additions and 3 deletions
@@ -225,6 +225,7 @@ export function DirectProjectChatView({
onRequestGamePublish={onRequestGamePublish}
/>
<DirectProjectConversation
conversationKey={projectPath ?? ''}
turns={directTurns}
historyHasMore={historyHasMore}
historyLoading={historyLoading}
@@ -15,7 +15,8 @@ const turn = (key: string): DirectChatTurn => ({
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 (
<DirectProjectConversation
conversationKey={overrides.conversationKey ?? '/projects/test'}
turns={overrides.turns ?? []}
historyHasMore={overrides.historyHasMore ?? false}
historyLoading={overrides.historyLoading ?? false}
@@ -35,10 +37,22 @@ function renderConversation(overrides: {
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();
});
@@ -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,
);
});
});
@@ -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,
@@ -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(
<Harness
conversationKey="/projects/a"
turns={[turn('t1')]}
historyHasMore={false}
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => undefined}
/>,
);
metrics.scrollTop = 0;
fireEvent.scroll(view.getByTestId('list'));
expect(view.getByTestId('capsule').textContent).toBe('回到底部');
return { metrics, view };
}
function switchToProjectB(view: ReturnType<typeof render>) {
view.rerender(
<Harness
conversationKey="/projects/b"
turns={[turn('t2')]}
historyHasMore={false}
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => 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(
<Harness
conversationKey="/projects/a"
turns={[turn('t1')]}
historyHasMore={false}
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => undefined}
/>,
);
metrics.scrollTop = 0;
fireEvent.scroll(view.getByTestId('list'));
switchToProjectB(view);
// 在 B 里重新往上滚:跟随关闭,之后内容再增加就该点亮「有新回复」。
metrics.scrollTop = 0;
fireEvent.scroll(view.getByTestId('list'));
view.rerender(
<Harness
conversationKey="/projects/b"
turns={[turn('t2'), turn('t3')]}
historyHasMore={false}
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => undefined}
/>,
);
expect(view.getByTestId('capsule').textContent).toBe('有新回复 · 回到底部');
});
});
@@ -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<ConversationToggleFold | null>(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);