mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
fix(webui): keep composer stable while scrolling
This commit is contained in:
parent
ae089aa3ae
commit
1faf0826f6
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user