diff --git a/bun.lock b/bun.lock index c6dc27c..c7b5808 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index 18c32de..21e5272 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ui/chat/chat-message-list.tsx b/src/components/ui/chat/chat-message-list.tsx index 70809a9..9a0acaf 100644 --- a/src/components/ui/chat/chat-message-list.tsx +++ b/src/components/ui/chat/chat-message-list.tsx @@ -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 { smooth?: boolean; } -const ChatMessageList = React.forwardRef( - // 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(); - return ( -
-
-
{children}
-
- - {!isAtBottom && ( - - )} -
- ); + if (isAtBottom) { + return null; } -); -ChatMessageList.displayName = "ChatMessageList"; + return ( + + ); +}; + +const ChatMessageList = ({ + className, + children, + smooth = false, + ...props +}: ChatMessageListProps) => { + const animation = smooth ? "smooth" : "instant"; + + return ( + + + {children} + + + + + ); +}; export { ChatMessageList }; diff --git a/src/components/ui/chat/hooks/useAutoScroll.tsx b/src/components/ui/chat/hooks/useAutoScroll.tsx deleted file mode 100644 index ae46381..0000000 --- a/src/components/ui/chat/hooks/useAutoScroll.tsx +++ /dev/null @@ -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(null); - const lastContentHeight = useRef(0); - const userHasScrolled = useRef(false); - - const [scrollState, setScrollState] = useState({ - 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, - }; -} diff --git a/src/features/problems/bot/components/form.tsx b/src/features/problems/bot/components/form.tsx index d4bd08d..3cf494e 100644 --- a/src/features/problems/bot/components/form.tsx +++ b/src/features/problems/bot/components/form.tsx @@ -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,49 +99,47 @@ export const BotForm = ({ ) ? (
- - - {messages - .filter( - (message) => - message.role === "user" || message.role === "assistant" - ) - .map((message) => { - const isUserMessage = message.role === "user"; - const isEmptyAssistantMessage = - message.role === "assistant" && - !message.content.trim() && - status !== "ready"; + + {messages + .filter( + (message) => + message.role === "user" || message.role === "assistant" + ) + .map((message) => { + const isUserMessage = message.role === "user"; + const isEmptyAssistantMessage = + message.role === "assistant" && + !message.content.trim() && + status !== "ready"; - return ( - + - - {isUserMessage ? ( - message.content - ) : ( - - )} - - - ); - })} - - + {isUserMessage ? ( + message.content + ) : ( + + )} + + + ); + })} +
) : (