fix: keep thread pinned during keyboard resize

This commit is contained in:
chengyongru 2026-06-14 21:04:36 +08:00 committed by Xubin Ren
parent c5a735549a
commit 4232e8547d
2 changed files with 198 additions and 154 deletions

View File

@ -51,6 +51,7 @@ const NEAR_TOP_PX = 96;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192; const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80; const SOFT_KEYBOARD_MIN_INSET_PX = 80;
const KEYBOARD_SCROLL_FRAMES = 18;
export const INITIAL_HISTORY_WINDOW = 160; export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120; export const HISTORY_WINDOW_INCREMENT = 120;
@ -166,10 +167,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const el = scrollRef.current; const el = scrollRef.current;
const marker = bottomRef.current; const marker = bottomRef.current;
const behavior: ScrollBehavior = smooth ? "smooth" : "auto"; const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
if (marker) { if (el) {
const top = Math.max(0, el.scrollHeight - el.clientHeight);
try {
el.scrollTo?.({ top, behavior });
if (!smooth) el.scrollTop = top;
} catch {
try {
el.scrollTop = top;
} catch {
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
}
}
} else if (marker) {
marker.scrollIntoView({ block: "end", behavior }); marker.scrollIntoView({ block: "end", behavior });
} else if (el) {
el.scrollTo({ top: el.scrollHeight, behavior });
} }
userReadingHistoryRef.current = false; userReadingHistoryRef.current = false;
setAtBottom(true); setAtBottom(true);
@ -183,14 +194,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (!force && userReadingHistoryRef.current) return; if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth); scrollToBottomNow(smooth);
}; };
run(); const scheduleNext = (remainingFrames: number) => {
for (let i = 1; i < frames; i += 1) { if (remainingFrames <= 0) return;
const id = window.requestAnimationFrame(() => { const id = window.requestAnimationFrame(() => {
scrollFrameIdsRef.current = scrollFrameIdsRef.current.filter((frameId) => frameId !== id);
if (!force && userReadingHistoryRef.current) return; if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth); scrollToBottomNow(smooth);
scheduleNext(remainingFrames - 1);
}); });
scrollFrameIdsRef.current.push(id); scrollFrameIdsRef.current.push(id);
} };
run();
scheduleNext(frames - 1);
}, },
[cancelScheduledBottomScroll, scrollToBottomNow], [cancelScheduledBottomScroll, scrollToBottomNow],
); );
@ -253,10 +268,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
useLayoutEffect(() => { useLayoutEffect(() => {
const updateKeyboardInset = () => { const updateKeyboardInset = () => {
const next = readSoftKeyboardInsetBottom(scrollRef.current); const scrollEl = scrollRef.current;
const next = readSoftKeyboardInsetBottom(scrollEl);
const active = document.activeElement;
const composerFocused =
hasMessages && isKeyboardEditableElement(active) && Boolean(scrollEl?.contains(active));
setKeyboardInsetBottom((current) => setKeyboardInsetBottom((current) =>
Math.abs(current - next) < 1 ? current : next, Math.abs(current - next) < 1 ? current : next,
); );
if (composerFocused) {
userReadingHistoryRef.current = false;
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
}
}; };
updateKeyboardInset(); updateKeyboardInset();
const viewport = window.visualViewport; const viewport = window.visualViewport;
@ -272,7 +295,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
document.removeEventListener("focusin", updateKeyboardInset); document.removeEventListener("focusin", updateKeyboardInset);
document.removeEventListener("focusout", updateKeyboardInset); document.removeEventListener("focusout", updateKeyboardInset);
}; };
}, []); }, [hasMessages, scrollToBottom]);
useEffect(() => { useEffect(() => {
if (!atBottom) return; if (!atBottom) return;
@ -284,7 +307,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
useLayoutEffect(() => { useLayoutEffect(() => {
if (keyboardInsetBottom > 0) { if (keyboardInsetBottom > 0) {
userReadingHistoryRef.current = false; userReadingHistoryRef.current = false;
scrollToBottom(false, 8, { force: true }); scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
return; return;
} }
if (userReadingHistoryRef.current) return; if (userReadingHistoryRef.current) return;
@ -299,7 +322,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const active = document.activeElement; const active = document.activeElement;
if (!hasMessages || !isKeyboardEditableElement(active) || !scrollEl.contains(active)) return; if (!hasMessages || !isKeyboardEditableElement(active) || !scrollEl.contains(active)) return;
userReadingHistoryRef.current = false; userReadingHistoryRef.current = false;
scrollToBottom(false, 8, { force: true }); scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
}; };
document.addEventListener("focusin", onComposerFocus); document.addEventListener("focusin", onComposerFocus);

View File

@ -222,9 +222,44 @@ describe("ThreadViewport", () => {
}); });
it("scrolls recent messages into view when the composer receives focus", async () => { it("scrolls recent messages into view when the composer receives focus", async () => {
const scrollIntoView = vi.fn(); const scrollTo = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; const { container } = render(
HTMLElement.prototype.scrollIntoView = scrollIntoView; <ThreadViewport
messages={messages}
isStreaming={false}
composer={<textarea aria-label="Message input" />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
const input = screen.getByLabelText("Message input");
act(() => {
input.focus();
fireEvent.focusIn(input);
});
await waitFor(() =>
expect(scrollTo).toHaveBeenCalledWith({
top: 1800,
behavior: "auto",
}),
);
});
it("scrolls recent messages into view when the focused composer resizes the visual viewport without an inset", async () => {
const visualViewport = stubVisualViewport({ innerHeight: 500, height: 500 });
const scrollTo = vi.fn();
try { try {
const { container } = render( const { container } = render(
@ -238,28 +273,30 @@ describe("ThreadViewport", () => {
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 }, scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
}); });
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
scrollIntoView.mockClear();
const input = screen.getByLabelText("Message input"); const input = screen.getByLabelText("Message input");
Object.defineProperty(document, "activeElement", {
configurable: true,
get: () => input,
});
act(() => { act(() => {
input.focus(); visualViewport.viewport.dispatchEvent(new Event("resize"));
fireEvent.focusIn(input);
}); });
await waitFor(() => await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({ expect(scrollTo).toHaveBeenCalledWith({
block: "end", top: 1800,
behavior: "auto", behavior: "auto",
}), }),
); );
expect(scroller).not.toHaveStyle({ bottom: "320px" });
} finally { } finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView; Reflect.deleteProperty(document, "activeElement");
visualViewport.restore();
} }
}); });
@ -589,148 +626,132 @@ describe("ThreadViewport", () => {
}); });
it("resets to the bottom when opening a different conversation", async () => { it("resets to the bottom when opening a different conversation", async () => {
const scrollIntoView = vi.fn(); const scrollTo = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; const { container, rerender } = render(
HTMLElement.prototype.scrollIntoView = scrollIntoView; <ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
try { rerender(
const { container, rerender } = render( <ThreadViewport
<ThreadViewport messages={messages}
messages={messages} isStreaming={false}
isStreaming={false} composer={<div />}
composer={<div />} conversationKey="chat-b"
conversationKey="chat-a" />,
/>, );
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
scrollIntoView.mockClear();
rerender( await waitFor(() =>
<ThreadViewport expect(scrollTo).toHaveBeenCalledWith({
messages={messages} top: 1800,
isStreaming={false} behavior: "auto",
composer={<div />} }),
conversationKey="chat-b" );
/>,
);
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "auto",
}),
);
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
}); });
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => { it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
const scrollIntoView = vi.fn(); const scrollTo = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; const { container, rerender } = render(
HTMLElement.prototype.scrollIntoView = scrollIntoView; <ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div />}
conversationKey={null}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 0 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
scrollTo.mockClear();
try { rerender(
const { container, rerender } = render( <ThreadViewport
<ThreadViewport messages={emptyMessages}
messages={emptyMessages} isStreaming={false}
isStreaming={false} composer={<div />}
composer={<div />} conversationKey="chat-a"
conversationKey={null} />,
/>, );
); expect(scrollTo).toHaveBeenCalledWith({
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; top: 0,
Object.defineProperty(scroller, "scrollHeight", { behavior: "auto",
configurable: true, });
value: 0,
});
scrollIntoView.mockClear();
rerender( Object.defineProperty(scroller, "scrollHeight", {
<ThreadViewport configurable: true,
messages={emptyMessages} value: 2400,
isStreaming={false} });
composer={<div />} scrollTo.mockClear();
conversationKey="chat-a"
/>, rerender(
); <ThreadViewport
expect(scrollIntoView).toHaveBeenCalledWith({ messages={messages}
block: "end", isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
await waitFor(() =>
expect(scrollTo).toHaveBeenCalledWith({
top: 1800,
behavior: "auto", behavior: "auto",
}); }),
);
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 2400,
});
scrollIntoView.mockClear();
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "auto",
}),
);
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
}); });
it("scrolls to the bottom when explicitly signalled after send", async () => { it("scrolls to the bottom when explicitly signalled after send", async () => {
const scrollIntoView = vi.fn(); const scrollTo = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; const { container, rerender } = render(
HTMLElement.prototype.scrollIntoView = scrollIntoView; <ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
scrollToBottomSignal={0}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
scrollTo.mockClear();
try { rerender(
const { container, rerender } = render( <ThreadViewport
<ThreadViewport messages={messages}
messages={messages} isStreaming={false}
isStreaming={false} composer={<div />}
composer={<div />} scrollToBottomSignal={1}
scrollToBottomSignal={0} />,
/>, );
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 2400,
});
scrollIntoView.mockClear();
rerender( await waitFor(() =>
<ThreadViewport expect(scrollTo).toHaveBeenCalledWith({
messages={messages} top: 1800,
isStreaming={false} behavior: "auto",
composer={<div />} }),
scrollToBottomSignal={1} );
/>,
);
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "auto",
}),
);
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
}); });
}); });