mirror of
https://github.com/cfngc4594/monaco-editor-lsp-next.git
synced 2026-05-31 10:18:52 +00:00
refactor(chat): use stick-to-bottom for message scrolling
This commit is contained in:
parent
0f85636779
commit
d87265eb2d
3
bun.lock
3
bun.lock
@ -81,6 +81,7 @@
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar-stream": "^3.1.7",
|
||||
"use-stick-to-bottom": "^1.1.4",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"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-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=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
@ -90,6 +90,7 @@
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar-stream": "^3.1.7",
|
||||
"use-stick-to-bottom": "^1.1.4",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vscode-languageclient": "^9.0.1",
|
||||
|
||||
@ -1,57 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAutoScroll } from "@/components/ui/chat/hooks/useAutoScroll";
|
||||
|
||||
interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
smooth?: boolean;
|
||||
}
|
||||
|
||||
const ChatMessageList = React.forwardRef<HTMLDivElement, ChatMessageListProps>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ className, children, smooth = false, ...props }, _ref) => {
|
||||
const {
|
||||
scrollRef,
|
||||
isAtBottom,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
autoScrollEnabled,
|
||||
scrollToBottom,
|
||||
disableAutoScroll,
|
||||
} = useAutoScroll({
|
||||
smooth,
|
||||
content: children,
|
||||
});
|
||||
const ScrollToBottomButton = () => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
if (isAtBottom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
}}
|
||||
size="icon"
|
||||
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"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -12,7 +12,6 @@ import { useTranslations } from "next-intl";
|
||||
import MdxPreview from "@/components/mdx-preview";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { BotIcon, SendHorizonal } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { PreDetail } from "@/components/content/pre-detail";
|
||||
import { TooltipButton } from "@/components/tooltip-button";
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
@ -100,7 +99,6 @@ export const BotForm = ({
|
||||
) ? (
|
||||
<div className="relative flex-1">
|
||||
<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>
|
||||
{messages
|
||||
.filter(
|
||||
@ -142,7 +140,6 @@ export const BotForm = ({
|
||||
);
|
||||
})}
|
||||
</ChatMessageList>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user