fix: keep scroll position when switching between cached conversations

This commit is contained in:
mesalogo
2026-10-02 15:52:48 +08:00
parent 1e10935d22
commit bf6c4a91a2
3 changed files with 34 additions and 7 deletions
+7
View File
@@ -4777,9 +4777,16 @@ describe("App", () => {
}); });
expect(messageRenderProbe).not.toHaveBeenCalled(); expect(messageRenderProbe).not.toHaveBeenCalled();
expect(firstPane?.querySelectorAll(".message")).toHaveLength(160); expect(firstPane?.querySelectorAll(".message")).toHaveLength(160);
const paneOrder = (): string[] =>
[...container.querySelectorAll<HTMLElement>(".chat-history-pane")].map(
(pane) => pane.dataset.conversationId ?? "",
);
const warmPaneOrder = paneOrder();
fireEvent.click(screen.getByText("第一段长会话").closest("button")!); fireEvent.click(screen.getByText("第一段长会话").closest("button")!);
expect(await screen.findByText("第一段历史 001")).toBeInTheDocument(); expect(await screen.findByText("第一段历史 001")).toBeInTheDocument();
// Chromium resets scrollTop when React moves a kept pane; jsdom does not.
expect(paneOrder()).toEqual(warmPaneOrder);
const restoredFirstPane = container.querySelector<HTMLElement>( const restoredFirstPane = container.querySelector<HTMLElement>(
`[data-conversation-id="${firstConversationId}"]`, `[data-conversation-id="${firstConversationId}"]`,
); );
+15 -7
View File
@@ -3594,18 +3594,26 @@ function App(): React.JSX.Element {
() => new Set(cachedWorkspaceViews.map((entry) => entry.key)), () => new Set(cachedWorkspaceViews.map((entry) => entry.key)),
[cachedWorkspaceViews], [cachedWorkspaceViews],
); );
// Kept panes must keep a stable DOM order. Reordering keyed siblings makes
// React detach and reinsert scroll containers, which resets their scrollTop.
const conversationPaneOrderRef = useRef<string[]>([]);
const cachedConversations = useMemo(() => { const cachedConversations = useMemo(() => {
const conversationById = new Map( const conversationById = new Map(
conversations.map((conversation) => [conversation.id, conversation]), conversations.map((conversation) => [conversation.id, conversation]),
); );
const cachedIds = [ const cachedIds = new Set(
activeId, [activeId, ...cachedConversationViews.map((entry) => entry.key)].filter(
...cachedConversationViews.map((entry) => entry.key), Boolean,
].filter( ),
(conversationId, index, values) =>
conversationId && values.indexOf(conversationId) === index,
); );
return cachedIds.flatMap((conversationId) => { const paneOrder = conversationPaneOrderRef.current.filter((id) =>
cachedIds.has(id),
);
cachedIds.forEach((id) => {
if (!paneOrder.includes(id)) paneOrder.push(id);
});
conversationPaneOrderRef.current = paneOrder;
return paneOrder.flatMap((conversationId) => {
const conversation = conversationById.get(conversationId); const conversation = conversationById.get(conversationId);
return conversation ? [conversation] : []; return conversation ? [conversation] : [];
}); });
+12
View File
@@ -108,6 +108,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true); const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true);
const latestScrollSnapshotRef = useRef(scrollSnapshot); const latestScrollSnapshotRef = useRef(scrollSnapshot);
const restorePendingRef = useRef(true); const restorePendingRef = useRef(true);
const wasActiveRef = useRef(false);
const prependScrollPositionRef = useRef< const prependScrollPositionRef = useRef<
| { | {
scrollHeight: number; scrollHeight: number;
@@ -234,12 +235,15 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!active) { if (!active) {
wasActiveRef.current = false;
return; return;
} }
const scrollContainer = scrollRef.current; const scrollContainer = scrollRef.current;
if (!scrollContainer) { if (!scrollContainer) {
return; return;
} }
const activated = !wasActiveRef.current;
wasActiveRef.current = true;
if (restorePendingRef.current) { if (restorePendingRef.current) {
restorePendingRef.current = false; restorePendingRef.current = false;
if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) { if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) {
@@ -247,6 +251,14 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
scrollContainer.scrollTop = scrollSnapshot.scrollTop; scrollContainer.scrollTop = scrollSnapshot.scrollTop;
return; return;
} }
} else if (activated && !pinnedToBottomRef.current) {
// Hidden panes may lose scrollTop when the browser detaches or re-lays
// them out; restore the reader's last position instead of the top.
const saved = latestScrollSnapshotRef.current;
if (saved && !saved.pinnedToBottom) {
scrollContainer.scrollTop = saved.scrollTop;
return;
}
} }
if (pinnedToBottomRef.current) { if (pinnedToBottomRef.current) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({