fix: keep scroll position when switching between cached conversations
This commit is contained in:
@@ -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}"]`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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] : [];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user