From 92d43a4f686c0f3a809b157b907e42d6272accf6 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:57:19 +0100 Subject: [PATCH] Studio: Replace assistant UI shared autoscroll with per-panel scrolling (#5127) * Replace assistant-ui's shared-store autoscroll with independent per-pane state - assistant-ui's useThreadViewportAutoScroll uses shared state that causes random scrolling issues on the compare page - Replaced with independent per-pane autoscroll implementation * Fix scroll-click scrolling for slow movements; polish sidebar animations and UI - Fix middle-click scroll not responding to very slow movements - Add animations to sidebar navigation icons; font adjustments - Align app menu styling for consistency - Fix rare lag on sidebar expand/collapse - Smooth right sidebar (parameter tuning) animations and expand/collapse lag * Address PR feedback: clean up unused code and polish sidebar - Remove unused _SuggestionItem component and its dependencies (SuggestionPrimitive import, SUGGESTION_TOOLS, toolIconMap) - Log delete-message errors to console before showing toast for better observability - Further sidebar menu design adjustments * Address PR feedback: auto-follow guard and NavItem cleanup - Prevent auto-follow from breaking on short threads: gate wheel/touch detach on scrollTop > 0 so no-op upward gestures on non-scrollable viewports don't flip userDetachedRef - Simplify NavItem: collapse identical-branch ternary and drop unused variant prop * Address PR feedback: model selector consistency and nested scroll fix - Adjust model selector to be consistent with the sidebar - Fix auto-follow breaking when user scrolls inside nested scrollable regions (reasoning/tool panels): gate wheel/touch detach on innerScrollWillConsumeUpward(target) so bubbled events from inner scrollers don't flip viewport intent (Codex review) * Filter layout-induced upward scroll deltas from auto-follow detach Accumulator now counts distanceFromBottom growth instead of raw -scrollTop delta, so browser scroll-anchoring on auto-collapsing panels (reasoning, tool outputs) no longer falsely detaches auto-follow mid-stream. --------- Co-authored-by: sneakr --- .../frontend/src/components/app-sidebar.tsx | 68 ++- .../src/components/assistant-ui/thread.tsx | 307 ++++++------ .../use-intent-aware-autoscroll.tsx | 451 ++++++++++++++++++ studio/frontend/src/components/ui/sidebar.tsx | 4 +- .../frontend/src/features/chat/chat-page.tsx | 31 +- .../src/features/chat/chat-settings-sheet.tsx | 2 +- studio/frontend/src/index.css | 30 +- 7 files changed, 670 insertions(+), 223 deletions(-) create mode 100644 studio/frontend/src/components/assistant-ui/use-intent-aware-autoscroll.tsx diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 959c2a6b31..6d029a59bd 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -123,7 +123,6 @@ function NavItem({ disabled, onClick, children, - variant = "nav", dataTour, }: { icon: typeof ZapIcon; @@ -132,10 +131,8 @@ function NavItem({ disabled?: boolean; onClick: () => void; children?: React.ReactNode; - variant?: "nav" | "menu"; dataTour?: string; }) { - const isNav = variant === "nav"; return (
@@ -145,14 +142,10 @@ function NavItem({ onClick={onClick} isActive={active} data-tour={dataTour} - className={ - isNav - ? "h-[30px] rounded-[8px] gap-2.5 px-2.5 font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec]! dark:hover:bg-[#2e3035]! hover:text-black! dark:hover:text-white! data-active:bg-[#ececec]! dark:data-active:bg-[#2e3035]! data-active:text-black! dark:data-active:text-white! group-data-[collapsible=icon]:!w-[30px] group-data-[collapsible=icon]:!rounded-[9px] group-data-[collapsible=icon]:mx-auto" - : "h-[30px] rounded-[8px] gap-2.5 px-2.5 font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec]! dark:hover:bg-[#2e3035]! hover:text-black! dark:hover:text-white! data-active:bg-[#ececec]! dark:data-active:bg-[#2e3035]! data-active:text-black! dark:data-active:text-white! group-data-[collapsible=icon]:!w-[30px] group-data-[collapsible=icon]:!rounded-[9px] group-data-[collapsible=icon]:mx-auto" - } + className="h-[32px] rounded-[10px] gap-[8.5px] px-2.5 font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white! group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[11px] group-data-[collapsible=icon]:mx-auto" > - - {label} + + {label}
{children} @@ -230,7 +223,7 @@ export function AppSidebar() { > {/* Expanded: compact logo + close toggle */} -
+
{ @@ -267,7 +260,7 @@ export function AppSidebar() { @@ -461,13 +454,13 @@ export function AppSidebar() { > { setSelectedHistoryRunId(run.id); closeMobileIfOpen(); }} > -
+
- + {run.model_name} @@ -501,7 +494,7 @@ export function AppSidebar() { } }} title="Delete" - className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-[8px] text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/run-item:scale-100 group-hover/run-item:opacity-100" + className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-[10px] text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/run-item:scale-100 group-hover/run-item:opacity-100" > @@ -516,7 +509,7 @@ export function AppSidebar() { )} - + @@ -524,7 +517,7 @@ export function AppSidebar() {
- {displayTitle} - Studio + {displayTitle} + Studio
@@ -544,24 +537,21 @@ export function AppSidebar() { useSettingsDialogStore.getState().openDialog()} > - + Settings ⌘, - - - } onSelect={(e) => { e.preventDefault(); toggleTheme(); }} > - {isDark ? : } + {isDark ? : } {isDark ? "Light Mode" : "Dark Mode"} - + Guided Tour - + - + Learn More @@ -600,7 +590,8 @@ export function AppSidebar() { > What's New @@ -613,15 +604,16 @@ export function AppSidebar() { > Feedback - + setShutdownOpen(true)}> - + Shutdown diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 4c6569c2ee..0b97d98dfd 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 { + IntentAwareScrollProvider, + 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"; @@ -30,14 +38,11 @@ import { ComposerPrimitive, ErrorPrimitive, MessagePrimitive, - SuggestionPrimitive, ThreadPrimitive, useAui, useAuiEvent, useAuiState, - useThreadViewport, } from "@assistant-ui/react"; -import { motion } from "motion/react"; import { ArrowDownIcon, ArrowUpIcon, @@ -50,9 +55,9 @@ import { HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, + LoaderIcon, MicIcon, MoreHorizontalIcon, - LoaderIcon, PencilIcon, RefreshCwIcon, SquareIcon, @@ -60,6 +65,7 @@ import { Trash2Icon, XIcon, } from "lucide-react"; +import { motion } from "motion/react"; import { type FC, type FormEvent, @@ -69,8 +75,6 @@ import { 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; @@ -81,6 +85,13 @@ export const Thread: FC<{ hideWelcome, targetThreadId, }) => { + // 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(); + const isComposerAttachPending = useAuiState(({ threads }) => targetThreadId ? threads.mainThreadId !== targetThreadId : false, ); @@ -94,154 +105,99 @@ export const Thread: FC<{ "calc(var(--thread-max-width) - 2.5rem)", }} > - - {!hideWelcome && ( - thread.isEmpty && !thread.isLoading}> - - - )} + + + {!hideWelcome && ( + thread.isEmpty && !thread.isLoading}> + + + )} - + - {/* 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. */} - hideWelcome || !thread.isEmpty}> -
- - - hideWelcome || !thread.isEmpty}> - - - - - - - {!hideComposer && ( - hideWelcome || !thread.isEmpty}> -
+ hideWelcome || !thread.isEmpty}>
-
-
- + + + hideWelcome || !thread.isEmpty}> + + + + + + + {!hideComposer && ( + hideWelcome || !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 IntentAwareScrollProvider (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 ( - - - - - - ); -}; - -const SUGGESTION_TOOLS: Record> = { - "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"], - "Draw an SVG of a cute sloth & show the code": ["thinking", "code", "search"], -}; - -const toolIconMap = { - thinking: { icon: LightbulbIcon, label: "Thinking" }, - search: { icon: GlobeIcon, label: "Web search" }, - code: { icon: TerminalIcon, label: "Code" }, -} as const; - -const SuggestionItem: FC = () => { - const aui = useAui(); - const prompt = useAuiState(({ suggestion }) => suggestion.prompt); - const isDisabled = useAuiState(({ thread }) => thread.isDisabled); - const isRunning = useAuiState(({ thread }) => thread.isRunning); - const tools = SUGGESTION_TOOLS[prompt] ?? []; - - return ( - + > + + ); }; @@ -273,7 +229,9 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => { const GeneratingSpinner: FC = () => { const status = useChatRuntimeStore((s) => s.generatingStatus); - if (!status) return null; + if (!status) { + return null; + } return (
@@ -302,7 +260,9 @@ const ComposerAnimated: FC<{ disabled?: boolean }> = ({ disabled }) => { const PendingAudioChip: FC = () => { const audioName = useChatRuntimeStore((s) => s.pendingAudioName); const clearPendingAudio = useChatRuntimeStore((s) => s.clearPendingAudio); - if (!audioName) return null; + if (!audioName) { + return null; + } return (
@@ -366,7 +326,9 @@ const ComposerAudioUpload: FC = () => { const handleAudioFile = useCallback( async (file: File) => { - if (file.size > MAX_AUDIO_SIZE) return; + if (file.size > MAX_AUDIO_SIZE) { + return; + } try { const base64 = await fileToBase64(file); setPendingAudio(base64, file.name); @@ -377,7 +339,9 @@ const ComposerAudioUpload: FC = () => { [setPendingAudio], ); - if (!activeModel?.hasAudioInput) return null; + if (!activeModel?.hasAudioInput) { + return null; + } return ( <> @@ -388,7 +352,9 @@ const ComposerAudioUpload: FC = () => { className="hidden" onChange={(e) => { const file = e.target.files?.[0]; - if (file) handleAudioFile(file); + if (file) { + handleAudioFile(file); + } e.target.value = ""; }} /> @@ -411,7 +377,9 @@ const ComposerAudioUpload: FC = () => { function applyQwenThinkingParams(thinkingOn: boolean): void { const store = useChatRuntimeStore.getState(); const checkpoint = store.params.checkpoint?.toLowerCase() ?? ""; - if (!checkpoint.includes("qwen3")) return; + if (!checkpoint.includes("qwen3")) { + return; + } const params = thinkingOn ? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 } : { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0 }; @@ -425,7 +393,7 @@ const ReasoningToggle: FC = () => { const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning); const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled); const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); - const disabled = !modelLoaded || !supportsReasoning; + const disabled = !(modelLoaded && supportsReasoning); return (