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",
|
"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=="],
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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 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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user