diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 54f64220e..5f65fcbbc 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -6,19 +6,27 @@ import { ComposerAttachments, UserMessageAttachments, } from "@/components/assistant-ui/attachment"; -import { MessageTiming } from "@/components/assistant-ui/message-timing"; +import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { MessageTiming } from "@/components/assistant-ui/message-timing"; import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning"; import { Sources, SourcesGroup } from "@/components/assistant-ui/sources"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { ToolGroup } from "@/components/assistant-ui/tool-group"; -import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search"; import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python"; import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal"; +import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon"; +import { + ScrollToBottomProvider, + useIntentAwareAutoScroll, + useIsThreadAtBottom, + useScrollThreadToBottom, +} from "@/components/assistant-ui/use-intent-aware-autoscroll"; import { Button } from "@/components/ui/button"; import { sentAudioNames } from "@/features/chat/api/chat-adapter"; +import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; +import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message"; import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { cn } from "@/lib/utils"; @@ -35,9 +43,7 @@ import { useAui, useAuiEvent, useAuiState, - useThreadViewport, } from "@assistant-ui/react"; -import { motion } from "motion/react"; import { ArrowDownIcon, ArrowUpIcon, @@ -50,9 +56,9 @@ import { HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, + LoaderIcon, MicIcon, MoreHorizontalIcon, - LoaderIcon, PencilIcon, RefreshCwIcon, SquareIcon, @@ -60,15 +66,21 @@ import { Trash2Icon, XIcon, } from "lucide-react"; +import { motion } from "motion/react"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message"; -import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; -export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ - hideComposer, - hideWelcome, -}) => { +export const Thread: FC<{ + hideComposer?: boolean; + hideWelcome?: boolean; +}> = ({ hideComposer, hideWelcome }) => { + // Intent-aware autoscroll: replaces assistant-ui's built-in autoscroll + // to prevent the streaming-mutation race that makes the viewport snap + // back to the bottom while the user is scrolling up (see the hook for + // the full explanation). + const { ref: viewportRef, context: autoScrollContext } = + useIntentAwareAutoScroll(); + return ( = ({ "calc(var(--thread-max-width) - 2.5rem)", }} > - - {!hideWelcome && ( - thread.isEmpty}> - - - )} + + + {!hideWelcome && ( + thread.isEmpty}> + + + )} - + - {/* Bottom slack so the last message has breathing room above the + {/* Bottom slack so the last message has breathing room above the sticky scroll-to-bottom button (and the floating composer in single mode). Without this, content would butt against the sticky footer and feel cramped. */} - !thread.isEmpty}> -
- - - !thread.isEmpty}> - - - - - - - {!hideComposer && ( - !thread.isEmpty}> -
+ !thread.isEmpty}>
-
-
- + + + !thread.isEmpty}> + + + + + + + {!hideComposer && ( + !thread.isEmpty}> +
+
+
+
+ +
+

+ LLMs can make mistakes. Double-check all responses. +

-

- LLMs can make mistakes. Double-check all responses. -

-
-
- )} + + )} + ); }; const ThreadScrollToBottom: FC = () => { - // Scoped to the nearest ThreadPrimitive.Root via context, so in compare - // mode each pane reads its own viewport state. - // - // The button stays mounted and toggles visibility via CSS. Conditionally - // rendering (return null) unmounts a DOM node inside the viewport, which - // the assistant-ui autoscroll hook's MutationObserver sees as a content - // change — during streaming that triggered spurious scroll-to-bottom - // calls, especially in the narrower mobile stacked layout. - const isAtBottom = useThreadViewport((vp) => vp.isAtBottom); + // State and action both come from our ScrollToBottomProvider (scoped + // per Thread, so compare panes are independent). We deliberately + // avoid `ThreadPrimitive.ScrollToBottom` + `useThreadViewport` to + // stay off assistant-ui's internal autoscroll path — see the hook + // for why. The button stays mounted and toggles via CSS; unmounting + // would trip the hook's MutationObserver as a content change. + const isAtBottom = useIsThreadAtBottom(); + const scrollToBottom = useScrollThreadToBottom(); return ( - - - - - + scrollToBottom("auto")} + className={cn( + "aui-thread-scroll-to-bottom pointer-events-auto rounded-full p-4 bg-background hover:bg-accent dark:bg-background dark:hover:bg-accent", + isAtBottom && "invisible pointer-events-none", + )} + > + + ); }; -const SUGGESTION_TOOLS: Record> = { +const SUGGESTION_TOOLS: Record< + string, + Array<"thinking" | "search" | "code"> +> = { "How do you fine-tune an audio model with Unsloth?": ["thinking", "search"], - "Create a live weather dashboard in HTML using no API key. Show me the code": ["thinking", "code", "search"], - "Solve the integral of x·sin(x), and verify it step by step": ["thinking", "code"], + "Create a live weather dashboard in HTML using no API key. Show me the code": + ["thinking", "code", "search"], + "Solve the integral of x·sin(x), and verify it step by step": [ + "thinking", + "code", + ], "Draw an SVG of a cute sloth & show the code": ["thinking", "code", "search"], }; @@ -182,7 +206,7 @@ const toolIconMap = { code: { icon: TerminalIcon, label: "Code" }, } as const; -const SuggestionItem: FC = () => { +const _SuggestionItem: FC = () => { const aui = useAui(); const prompt = useAuiState(({ suggestion }) => suggestion.prompt); const isDisabled = useAuiState(({ thread }) => thread.isDisabled); @@ -193,7 +217,7 @@ const SuggestionItem: FC = () => {