fix: keep thread pinned during keyboard resize

This commit is contained in:
chengyongru
2026-06-15 02:54:37 +08:00
committed by Xubin Ren
parent c5a735549a
commit 4232e8547d
2 changed files with 198 additions and 154 deletions
+33 -10
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);
+78 -57
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,11 +626,7 @@ 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;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
const { container, rerender } = render( const { container, rerender } = render(
<ThreadViewport <ThreadViewport
messages={messages} messages={messages}
@@ -606,12 +639,13 @@ 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(() => { act(() => {
scroller.dispatchEvent(new Event("scroll")); scroller.dispatchEvent(new Event("scroll"));
}); });
scrollIntoView.mockClear(); scrollTo.mockClear();
rerender( rerender(
<ThreadViewport <ThreadViewport
@@ -623,22 +657,15 @@ describe("ThreadViewport", () => {
); );
await waitFor(() => await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({ expect(scrollTo).toHaveBeenCalledWith({
block: "end", top: 1800,
behavior: "auto", 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;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
const { container, rerender } = render( const { container, rerender } = render(
<ThreadViewport <ThreadViewport
messages={emptyMessages} messages={emptyMessages}
@@ -648,11 +675,13 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", { Object.defineProperties(scroller, {
configurable: true, scrollHeight: { configurable: true, value: 0 },
value: 0, clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
}); });
scrollIntoView.mockClear(); scrollTo.mockClear();
rerender( rerender(
<ThreadViewport <ThreadViewport
@@ -662,8 +691,8 @@ describe("ThreadViewport", () => {
conversationKey="chat-a" conversationKey="chat-a"
/>, />,
); );
expect(scrollIntoView).toHaveBeenCalledWith({ expect(scrollTo).toHaveBeenCalledWith({
block: "end", top: 0,
behavior: "auto", behavior: "auto",
}); });
@@ -671,7 +700,7 @@ describe("ThreadViewport", () => {
configurable: true, configurable: true,
value: 2400, value: 2400,
}); });
scrollIntoView.mockClear(); scrollTo.mockClear();
rerender( rerender(
<ThreadViewport <ThreadViewport
@@ -683,22 +712,15 @@ describe("ThreadViewport", () => {
); );
await waitFor(() => await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({ expect(scrollTo).toHaveBeenCalledWith({
block: "end", top: 1800,
behavior: "auto", 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;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
const { container, rerender } = render( const { container, rerender } = render(
<ThreadViewport <ThreadViewport
messages={messages} messages={messages}
@@ -708,11 +730,13 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", { Object.defineProperties(scroller, {
configurable: true, scrollHeight: { configurable: true, value: 2400 },
value: 2400, clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
}); });
scrollIntoView.mockClear(); scrollTo.mockClear();
rerender( rerender(
<ThreadViewport <ThreadViewport
@@ -724,13 +748,10 @@ describe("ThreadViewport", () => {
); );
await waitFor(() => await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({ expect(scrollTo).toHaveBeenCalledWith({
block: "end", top: 1800,
behavior: "auto", behavior: "auto",
}), }),
); );
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
}); });
}); });