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(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")!);
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>(
`[data-conversation-id="${firstConversationId}"]`,
);
+15 -7
View File
@@ -3594,18 +3594,26 @@ function App(): React.JSX.Element {
() => new Set(cachedWorkspaceViews.map((entry) => entry.key)),
[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 conversationById = new Map(
conversations.map((conversation) => [conversation.id, conversation]),
);
const cachedIds = [
activeId,
...cachedConversationViews.map((entry) => entry.key),
].filter(
(conversationId, index, values) =>
conversationId && values.indexOf(conversationId) === index,
const cachedIds = new Set(
[activeId, ...cachedConversationViews.map((entry) => entry.key)].filter(
Boolean,
),
);
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);
return conversation ? [conversation] : [];
});
+12
View File
@@ -108,6 +108,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true);
const latestScrollSnapshotRef = useRef(scrollSnapshot);
const restorePendingRef = useRef(true);
const wasActiveRef = useRef(false);
const prependScrollPositionRef = useRef<
| {
scrollHeight: number;
@@ -234,12 +235,15 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
useLayoutEffect(() => {
if (!active) {
wasActiveRef.current = false;
return;
}
const scrollContainer = scrollRef.current;
if (!scrollContainer) {
return;
}
const activated = !wasActiveRef.current;
wasActiveRef.current = true;
if (restorePendingRef.current) {
restorePendingRef.current = false;
if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) {
@@ -247,6 +251,14 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
scrollContainer.scrollTop = scrollSnapshot.scrollTop;
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) {
scrollContainer.scrollTo({