fix(webui): keep composer stable while scrolling

This commit is contained in:
chengyongru 2026-07-28 17:09:16 +08:00 committed by chengyongru
parent ae089aa3ae
commit 1faf0826f6
3 changed files with 134 additions and 51 deletions

View File

@ -174,7 +174,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
onQuoteSelection,
}, ref) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const viewportFrameRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const messageRegionRef = useRef<HTMLDivElement>(null);
const messageContentRef = useRef<HTMLDivElement>(null);
@ -236,6 +237,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
});
}
const hasMessages = messages.length > 0;
useLayoutEffect(() => {
scrollRef.current = hasMessages
? messageRegionRef.current
: viewportFrameRef.current;
}, [hasMessages]);
const visibleMessages = useMemo(
() => windowMessages(messages, visibleMessageCount),
[messages, visibleMessageCount],
@ -360,13 +366,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
useLayoutEffect(() => {
const updateKeyboardInset = () => {
const scrollEl = scrollRef.current;
const next = readSoftKeyboardInsetBottom(scrollEl);
const composerDock = composerDockRef.current;
const next = readSoftKeyboardInsetBottom(composerDock);
const active = document.activeElement;
const composerFocused =
hasMessages
&& isKeyboardEditableElement(active)
&& Boolean(scrollEl?.contains(active));
&& Boolean(composerDock?.contains(active));
setKeyboardInsetBottom((current) =>
Math.abs(current - next) < 1 ? current : next,
);
@ -609,17 +615,22 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
el.removeEventListener("pointerdown", handlePointerDown);
el.removeEventListener("keydown", handleKeyDown);
};
}, [maybeLoadEarlierFromScroll, yieldCameraToUser]);
}, [hasMessages, maybeLoadEarlierFromScroll, yieldCameraToUser]);
return (
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
ref={viewportFrameRef}
className={cn(
"thread-viewport-scrollbar absolute inset-0 scroll-auto",
"[overflow-anchor:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden",
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
"thread-viewport-frame absolute inset-0",
hasMessages
? "overflow-hidden"
: cn(
"thread-viewport-scrollbar scroll-auto",
"[overflow-anchor:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden",
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
),
)}
style={scrollViewportStyle}
>
@ -630,7 +641,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
className={cn(
"thread-layout mx-auto grid min-h-full w-full",
hasMessages
? "max-w-[64rem]"
? "h-full max-w-[64rem]"
: "max-w-[72rem] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-6 sm:px-4 sm:py-12",
)}
>
@ -638,7 +649,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div
ref={messageRegionRef}
data-testid="thread-message-region"
className="row-start-1 flex min-h-0 min-w-0 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
className={cn(
"thread-viewport-scrollbar row-start-1 flex min-h-0 min-w-0 flex-col",
"scroll-auto justify-start overflow-x-hidden px-3 pb-4 pt-4 sm:px-4",
"[overflow-anchor:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden",
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
)}
>
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages
@ -654,6 +671,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
onQuoteSelection={onQuoteSelection}
/>
</div>
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
</div>
) : (
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
@ -671,7 +689,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
}}
className={cn(
"row-start-2 z-10 w-full",
hasMessages ? "sticky bottom-0 bg-background" : "relative self-center",
hasMessages ? "relative bg-background" : "relative self-center",
)}
>
<div
@ -695,7 +713,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
/>
</div>
<div ref={bottomRef} aria-hidden className="h-px" />
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
</div>
<div

View File

@ -355,6 +355,9 @@
transition:
grid-template-rows 900ms cubic-bezier(0.33, 1, 0.68, 1);
}
.thread-layout[data-layout="thread"] {
grid-template-rows: minmax(0, 1fr) auto 0fr;
}
@media (min-width: 640px) {
.thread-layout[data-layout="hero"] {
grid-template-rows: minmax(min-content, 1fr) auto 1fr;

View File

@ -148,6 +148,12 @@ function makePromptExchangeMessages(count: number): UIMessage[] {
])).flat();
}
function getScroller(container: HTMLElement): HTMLElement {
const scroller = container.querySelector<HTMLElement>(".thread-viewport-scrollbar");
if (!scroller) throw new Error("thread scrollport not found");
return scroller;
}
async function renderPromptRailViewport({
scrollTo,
}: {
@ -162,7 +168,7 @@ async function renderPromptRailViewport({
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1800 },
clientHeight: { configurable: true, value: 600 },
@ -260,6 +266,26 @@ describe("ThreadViewport", () => {
expect(screen.getByTestId("thread-composer-dock")).not.toHaveClass("mt-auto");
});
it("keeps the docked composer outside the message scrollport", () => {
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div>composer</div>}
/>,
);
const scroller = getScroller(container);
const messageRegion = screen.getByTestId("thread-message-region");
const composerDock = screen.getByTestId("thread-composer-dock");
expect(scroller).toBe(messageRegion);
expect(scroller).not.toContainElement(composerDock);
expect(scroller.parentElement).toContainElement(composerDock);
expect(composerDock).toHaveClass("relative");
expect(composerDock).not.toHaveClass("sticky");
expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0");
});
it("pins a waiting prompt to the exact lower scroll boundary", async () => {
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
const threaded: UIMessage[] = [
@ -276,7 +302,7 @@ describe("ThreadViewport", () => {
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1200 },
clientHeight: { configurable: true, value: 500 },
@ -323,7 +349,7 @@ describe("ThreadViewport", () => {
composer={<div>composer</div>}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1_200 },
clientHeight: { configurable: true, value: 500 },
@ -367,6 +393,13 @@ describe("ThreadViewport", () => {
it("lets the first prompt supersede a pending empty-conversation camera command", async () => {
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
const scrollTo = vi.fn();
const firstPrompt: UIMessage = {
id: "u-first",
role: "user",
content: "first question",
turnId: "turn-first",
createdAt: 1,
};
const { container, rerender } = render(
<ThreadViewport
messages={emptyMessages}
@ -375,7 +408,7 @@ describe("ThreadViewport", () => {
conversationKey={null}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1200 },
clientHeight: { configurable: true, value: 500 },
@ -388,18 +421,32 @@ describe("ThreadViewport", () => {
await act(async () => {
rerender(
<ThreadViewport
messages={[
{
id: "u-first",
role: "user",
content: "first question",
turnId: "turn-first",
createdAt: 1,
},
]}
messages={[firstPrompt]}
isStreaming
composer={<div>composer</div>}
conversationKey="chat-a"
conversationReady={false}
activeTurnId="turn-first"
activeTurnStartedHere
/>,
);
});
const threadScroller = getScroller(container);
Object.defineProperties(threadScroller, {
scrollHeight: { configurable: true, value: 1200 },
clientHeight: { configurable: true, value: 500 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
jumpTo.mockClear();
await act(async () => {
rerender(
<ThreadViewport
messages={[firstPrompt]}
isStreaming
composer={<div>composer</div>}
conversationKey="chat-a"
conversationReady
activeTurnId="turn-first"
activeTurnStartedHere
/>,
@ -430,7 +477,7 @@ describe("ThreadViewport", () => {
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1904 },
clientHeight: { configurable: true, value: 500 },
@ -494,7 +541,7 @@ describe("ThreadViewport", () => {
composer={<div>composer</div>}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2_000 },
clientHeight: { configurable: true, value: 500 },
@ -603,7 +650,7 @@ describe("ThreadViewport", () => {
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1904 },
clientHeight: { configurable: true, value: 500 },
@ -726,7 +773,7 @@ describe("ThreadViewport", () => {
composer={<div>composer</div>}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
@ -791,7 +838,7 @@ describe("ThreadViewport", () => {
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1200 },
clientHeight: { configurable: true, value: 500 },
@ -886,7 +933,7 @@ describe("ThreadViewport", () => {
/>
);
const { container, rerender } = render(viewport(true));
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1_200 },
clientHeight: { configurable: true, value: 500 },
@ -956,7 +1003,9 @@ describe("ThreadViewport", () => {
composer={<textarea aria-label="Message input" />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
const viewportFrame = container.querySelector(".thread-viewport-frame");
expect(viewportFrame).not.toBeNull();
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
@ -973,13 +1022,13 @@ describe("ThreadViewport", () => {
fireEvent.focusIn(input);
});
await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" }));
await waitFor(() => expect(viewportFrame).toHaveStyle({ bottom: "320px" }));
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
act(() => {
visualViewport.viewport.dispatchEvent(new Event("resize"));
});
expect(scroller).toHaveStyle({ bottom: "320px" });
expect(viewportFrame).toHaveStyle({ bottom: "320px" });
} finally {
visualViewport.restore();
}
@ -1112,7 +1161,7 @@ describe("ThreadViewport", () => {
composer={<textarea aria-label="Message input" />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
@ -1146,7 +1195,7 @@ describe("ThreadViewport", () => {
composer={<textarea aria-label="Message input" />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
@ -1182,7 +1231,7 @@ describe("ThreadViewport", () => {
showScrollToBottomButton={false}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
@ -1224,7 +1273,7 @@ describe("ThreadViewport", () => {
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
@ -1258,7 +1307,7 @@ describe("ThreadViewport", () => {
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1800 },
clientHeight: { configurable: true, value: 600 },
@ -1414,7 +1463,7 @@ describe("ThreadViewport", () => {
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 700 },
clientHeight: { configurable: true, value: 600 },
@ -1452,7 +1501,7 @@ describe("ThreadViewport", () => {
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
const scrollTo = vi.fn();
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 10000 },
@ -1536,7 +1585,7 @@ describe("ThreadViewport", () => {
conversationKey="chat-a"
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
@ -1597,7 +1646,7 @@ describe("ThreadViewport", () => {
activeTurnId="old-turn"
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
@ -1650,7 +1699,7 @@ describe("ThreadViewport", () => {
conversationKey={null}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 0 },
clientHeight: { configurable: true, value: 600 },
@ -1670,9 +1719,21 @@ describe("ThreadViewport", () => {
);
expect(jumpTo).toHaveBeenCalledWith(0);
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 2400,
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
conversationReady={false}
/>,
);
const hydratedScroller = getScroller(container);
Object.defineProperties(hydratedScroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
scrollTo.mockClear();
jumpTo.mockClear();
@ -1683,10 +1744,11 @@ describe("ThreadViewport", () => {
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
conversationReady
/>,
);
await waitFor(() => expect(scroller.scrollTop).toBe(1800));
await waitFor(() => expect(hydratedScroller.scrollTop).toBe(1800));
expect(jumpTo).toHaveBeenCalledWith(1800);
});
@ -1700,7 +1762,7 @@ describe("ThreadViewport", () => {
scrollToBottomSignal={0}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },