refactor(chat): use stick-to-bottom for message scrolling

This commit is contained in:
cfngc4594 2026-05-29 13:36:57 +08:00
parent 0f85636779
commit d87265eb2d
5 changed files with 88 additions and 220 deletions

View File

@ -81,6 +81,7 @@
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tar-stream": "^3.1.7", "tar-stream": "^3.1.7",
"use-stick-to-bottom": "^1.1.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"vscode-languageclient": "^9.0.1", "vscode-languageclient": "^9.0.1",
@ -2137,6 +2138,8 @@
"use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-stick-to-bottom": ["use-stick-to-bottom@1.1.4", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2w/lydkrwhWMv1vCaEhYbzMDhgbwIodHpAHPV0/xKJErRkbjDEUe1EWmvr6Fwb+qhiERjc1EWgAEZaSaF69CpA=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], "use-sync-external-store": ["use-sync-external-store@1.5.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],

View File

@ -90,6 +90,7 @@
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tar-stream": "^3.1.7", "tar-stream": "^3.1.7",
"use-stick-to-bottom": "^1.1.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"vscode-languageclient": "^9.0.1", "vscode-languageclient": "^9.0.1",

View File

@ -1,57 +1,59 @@
import * as React from "react"; import * as React from "react";
import { ArrowDown } from "lucide-react"; import { ArrowDown } from "lucide-react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAutoScroll } from "@/components/ui/chat/hooks/useAutoScroll";
interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> { interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> {
smooth?: boolean; smooth?: boolean;
} }
const ChatMessageList = React.forwardRef<HTMLDivElement, ChatMessageListProps>( const ScrollToBottomButton = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars const { isAtBottom, scrollToBottom } = useStickToBottomContext();
({ className, children, smooth = false, ...props }, _ref) => {
const { if (isAtBottom) {
scrollRef, return null;
isAtBottom, }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
autoScrollEnabled,
scrollToBottom,
disableAutoScroll,
} = useAutoScroll({
smooth,
content: children,
});
return ( return (
<div className="relative w-full h-full">
<div
className={`flex flex-col w-full h-full p-4 overflow-y-auto ${className}`}
ref={scrollRef}
onWheel={disableAutoScroll}
onTouchMove={disableAutoScroll}
{...props}
>
<div className="flex flex-col gap-6">{children}</div>
</div>
{!isAtBottom && (
<Button <Button
onClick={() => { onClick={() => {
scrollToBottom(); scrollToBottom();
}} }}
size="icon" size="icon"
variant="outline" variant="outline"
className="absolute bottom-2 left-1/2 transform -translate-x-1/2 inline-flex rounded-full shadow-md" className="absolute bottom-2 left-1/2 inline-flex -translate-x-1/2 rounded-full shadow-md"
aria-label="Scroll to bottom" aria-label="Scroll to bottom"
> >
<ArrowDown className="h-4 w-4" /> <ArrowDown className="h-4 w-4" />
</Button> </Button>
)}
</div>
); );
} };
);
ChatMessageList.displayName = "ChatMessageList"; const ChatMessageList = ({
className,
children,
smooth = false,
...props
}: ChatMessageListProps) => {
const animation = smooth ? "smooth" : "instant";
return (
<StickToBottom
className="relative h-full w-full"
initial="instant"
resize={animation}
>
<StickToBottom.Content
className="flex flex-col gap-6"
scrollClassName={`h-full w-full p-4 ${className ?? ""}`}
{...props}
>
{children}
</StickToBottom.Content>
<ScrollToBottomButton />
</StickToBottom>
);
};
export { ChatMessageList }; export { ChatMessageList };

View File

@ -1,135 +0,0 @@
// @hidden
import { useCallback, useEffect, useRef, useState } from "react";
interface ScrollState {
isAtBottom: boolean;
autoScrollEnabled: boolean;
}
interface UseAutoScrollOptions {
offset?: number;
smooth?: boolean;
content?: React.ReactNode;
}
export function useAutoScroll(options: UseAutoScrollOptions = {}) {
const { offset = 20, smooth = false, content } = options;
const scrollRef = useRef<HTMLDivElement>(null);
const lastContentHeight = useRef(0);
const userHasScrolled = useRef(false);
const [scrollState, setScrollState] = useState<ScrollState>({
isAtBottom: true,
autoScrollEnabled: true,
});
const checkIsAtBottom = useCallback(
(element: HTMLElement) => {
const { scrollTop, scrollHeight, clientHeight } = element;
const distanceToBottom = Math.abs(
scrollHeight - scrollTop - clientHeight
);
return distanceToBottom <= offset;
},
[offset]
);
const scrollToBottom = useCallback(
(instant?: boolean) => {
if (!scrollRef.current) return;
const targetScrollTop =
scrollRef.current.scrollHeight - scrollRef.current.clientHeight;
if (instant) {
scrollRef.current.scrollTop = targetScrollTop;
} else {
scrollRef.current.scrollTo({
top: targetScrollTop,
behavior: smooth ? "smooth" : "auto",
});
}
setScrollState({
isAtBottom: true,
autoScrollEnabled: true,
});
userHasScrolled.current = false;
},
[smooth]
);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
const atBottom = checkIsAtBottom(scrollRef.current);
setScrollState((prev) => ({
isAtBottom: atBottom,
// Re-enable auto-scroll if at the bottom
autoScrollEnabled: atBottom ? true : prev.autoScrollEnabled,
}));
}, [checkIsAtBottom]);
useEffect(() => {
const element = scrollRef.current;
if (!element) return;
element.addEventListener("scroll", handleScroll, { passive: true });
return () => element.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const currentHeight = scrollElement.scrollHeight;
const hasNewContent = currentHeight !== lastContentHeight.current;
if (hasNewContent) {
if (scrollState.autoScrollEnabled) {
requestAnimationFrame(() => {
scrollToBottom(lastContentHeight.current === 0);
});
}
lastContentHeight.current = currentHeight;
}
}, [content, scrollState.autoScrollEnabled, scrollToBottom]);
useEffect(() => {
const element = scrollRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver(() => {
if (scrollState.autoScrollEnabled) {
scrollToBottom(true);
}
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, [scrollState.autoScrollEnabled, scrollToBottom]);
const disableAutoScroll = useCallback(() => {
const atBottom = scrollRef.current
? checkIsAtBottom(scrollRef.current)
: false;
// Only disable if not at bottom
if (!atBottom) {
userHasScrolled.current = true;
setScrollState((prev) => ({
...prev,
autoScrollEnabled: false,
}));
}
}, [checkIsAtBottom]);
return {
scrollRef,
isAtBottom: scrollState.isAtBottom,
autoScrollEnabled: scrollState.autoScrollEnabled,
scrollToBottom: () => scrollToBottom(false),
disableAutoScroll,
};
}

View File

@ -12,7 +12,6 @@ import { useTranslations } from "next-intl";
import MdxPreview from "@/components/mdx-preview"; import MdxPreview from "@/components/mdx-preview";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { BotIcon, SendHorizonal } from "lucide-react"; import { BotIcon, SendHorizonal } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { PreDetail } from "@/components/content/pre-detail"; import { PreDetail } from "@/components/content/pre-detail";
import { TooltipButton } from "@/components/tooltip-button"; import { TooltipButton } from "@/components/tooltip-button";
import { useProblemEditorStore } from "@/stores/problem-editor"; import { useProblemEditorStore } from "@/stores/problem-editor";
@ -100,7 +99,6 @@ export const BotForm = ({
) ? ( ) ? (
<div className="relative flex-1"> <div className="relative flex-1">
<div className="absolute h-full w-full"> <div className="absolute h-full w-full">
<ScrollArea className="h-full [&>[data-radix-scroll-area-viewport]>div:min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block">
<ChatMessageList> <ChatMessageList>
{messages {messages
.filter( .filter(
@ -142,7 +140,6 @@ export const BotForm = ({
); );
})} })}
</ChatMessageList> </ChatMessageList>
</ScrollArea>
</div> </div>
</div> </div>
) : ( ) : (