修复:切项目时复位 DirectProject 对话滚动所有权
useConversationScroll 新增 conversationKey 身份信号,身份变化时复位跟随最新 / 贴底 / 有新回复 / 前插锚点 / 折叠头 / 程序化滚动标记 复位时把终态指纹同步成新会话内容并重新贴底,避免同一次提交里的内容变化被算成「有新回复」 DirectProjectConversation 透传身份键,DirectProjectChatView 传项目路径,不重建列表 DOM 新增 3 条换会话用例与 1 条组件用例,组件用例抽成 conversationElement 以便 rerender Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -225,6 +225,7 @@ export function DirectProjectChatView({
|
||||
onRequestGamePublish={onRequestGamePublish}
|
||||
/>
|
||||
<DirectProjectConversation
|
||||
conversationKey={projectPath ?? ''}
|
||||
turns={directTurns}
|
||||
historyHasMore={historyHasMore}
|
||||
historyLoading={historyLoading}
|
||||
|
||||
+39
-3
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+101
@@ -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('有新回复 · 回到底部');
|
||||
});
|
||||
});
|
||||
|
||||
+27
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user