diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index 0e37f6d433..6b2c7a05e7 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -17,7 +17,6 @@ import { type ReasoningGroupComponent, type ReasoningMessagePartComponent, useAuiState, - useScrollLock, } from "@assistant-ui/react"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { Idea01Icon } from "@hugeicons/core-free-icons"; @@ -34,6 +33,7 @@ import { useState, } from "react"; const ANIMATION_DURATION = 200; +const AUTO_SCROLL_THRESHOLD_PX = 24; export const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", { variants: { @@ -68,8 +68,49 @@ function ReasoningRoot({ ...props }: ReasoningRootProps) { const collapsibleRef = useRef(null); + const lockCleanupRef = useRef<(() => void) | null>(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + + useEffect(() => { + return () => { + lockCleanupRef.current?.(); + }; + }, []); + + const lockScroll = useCallback(() => { + lockCleanupRef.current?.(); + + const animatedElement = collapsibleRef.current; + if (!animatedElement) return; + + let scrollContainer: HTMLElement | null = animatedElement; + while (scrollContainer) { + const { overflowY } = getComputedStyle(scrollContainer); + if (overflowY === "scroll" || overflowY === "auto") { + break; + } + scrollContainer = scrollContainer.parentElement; + } + if (!scrollContainer) return; + + const scrollPosition = scrollContainer.scrollTop; + const resetPosition = () => { + scrollContainer.scrollTop = scrollPosition; + }; + + scrollContainer.addEventListener("scroll", resetPosition); + let timeoutId: ReturnType | null = null; + const cleanup = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + scrollContainer.removeEventListener("scroll", resetPosition); + lockCleanupRef.current = null; + }; + timeoutId = setTimeout(cleanup, ANIMATION_DURATION); + lockCleanupRef.current = cleanup; + }, []); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; @@ -220,6 +261,8 @@ function ReasoningText({ }: ComponentProps<"div"> & { streaming?: boolean }) { const scrollRef = useRef(null); const shouldAutoScrollRef = useRef(true); + const detachedFromBottomRef = useRef(false); + const lastScrollTopRef = useRef(0); useEffect(() => { if (!(streaming && scrollRef.current)) { @@ -227,8 +270,25 @@ function ReasoningText({ } const el = scrollRef.current; const updateAutoScroll = () => { + const currentScrollTop = el.scrollTop; + if (currentScrollTop < lastScrollTopRef.current) { + detachedFromBottomRef.current = true; + } const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - shouldAutoScrollRef.current = distanceFromBottom <= 24; + if ( + detachedFromBottomRef.current && + distanceFromBottom <= AUTO_SCROLL_THRESHOLD_PX + ) { + detachedFromBottomRef.current = false; + } + shouldAutoScrollRef.current = !detachedFromBottomRef.current; + lastScrollTopRef.current = currentScrollTop; + }; + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0) { + detachedFromBottomRef.current = true; + shouldAutoScrollRef.current = false; + } }; const observer = new MutationObserver(() => { if (shouldAutoScrollRef.current) { @@ -236,16 +296,19 @@ function ReasoningText({ } }); el.addEventListener("scroll", updateAutoScroll); + el.addEventListener("wheel", handleWheel, { passive: true }); observer.observe(el, { childList: true, subtree: true, characterData: true, }); - shouldAutoScrollRef.current = true; - el.scrollTop = el.scrollHeight; + lastScrollTopRef.current = el.scrollTop; + detachedFromBottomRef.current = false; + updateAutoScroll(); return () => { observer.disconnect(); el.removeEventListener("scroll", updateAutoScroll); + el.removeEventListener("wheel", handleWheel); }; }, [streaming]); diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 0c07e133fb..d688822815 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -89,7 +89,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ }} /> - + !thread.isEmpty}> {!hideComposer && } diff --git a/studio/frontend/src/components/ui/collapsible.tsx b/studio/frontend/src/components/ui/collapsible.tsx index 3566eb9859..df5347c1a7 100644 --- a/studio/frontend/src/components/ui/collapsible.tsx +++ b/studio/frontend/src/components/ui/collapsible.tsx @@ -2,13 +2,18 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { cn } from "@/lib/utils"; +import * as React from "react"; import { Collapsible as CollapsiblePrimitive } from "radix-ui"; -function Collapsible({ - ...props -}: React.ComponentProps) { - return ; -} +const Collapsible = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ( + + ); +}); +Collapsible.displayName = CollapsiblePrimitive.Root.displayName; function CollapsibleTrigger({ ...props