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 <hauzin@hotmail.com>
This commit is contained in:
Lee Jackson 2026-04-22 15:57:19 +01:00 committed by GitHub
commit 92d43a4f68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 671 additions and 224 deletions

View file

@ -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 (
<SidebarMenuItem>
<div className="relative">
@ -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"
>
<HugeiconsIcon icon={icon} strokeWidth={1.5} className="size-[18px]!" />
<span className="text-sm">{label}</span>
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-[18px]! shrink-0 group-hover/menu-button:animate-icon-pop" />
<span className="text-[14px] leading-[18px] tracking-[0.01em]">{label}</span>
</SidebarMenuButton>
</div>
{children}
@ -230,7 +223,7 @@ export function AppSidebar() {
>
<SidebarHeader className="pl-[17px] pr-3 pt-[12px] pb-[12px] group-data-[collapsible=icon]:px-0">
{/* Expanded: compact logo + close toggle */}
<div className="flex items-center justify-between gap-2 group-data-[collapsible=icon]:hidden">
<div className="flex items-center justify-between gap-[8.5px] group-data-[collapsible=icon]:hidden">
<Link
to="/chat"
onClick={(event) => {
@ -267,7 +260,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-[8px] text-[#8f8f8f] dark:text-[#5c5c5c] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px] text-[#8f8f8f] dark:text-[#5c5c5c] transition-colors hover:bg-[#f0f0f0] dark:hover:bg-[#2a2c2f] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close sidebar"
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
@ -288,7 +281,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#f0f0f0] dark:hover:bg-[#2a2c2f] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open sidebar"
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
@ -302,7 +295,7 @@ export function AppSidebar() {
)}
</SidebarHeader>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[8px] pb-[12px] shrink-0">
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[10px] pb-[14px] shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
@ -347,7 +340,7 @@ export function AppSidebar() {
<SidebarContent className="gap-0 overflow-y-auto overscroll-contain min-h-0">
{/* Navigate (no header) */}
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[8px] pb-[12px]">
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[10px] pb-[14px]">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
@ -404,7 +397,7 @@ export function AppSidebar() {
<SidebarMenuItem key={item.id} className="group/recent-item relative">
<SidebarMenuButton
isActive={activeThreadId === item.id}
className="h-[30px] rounded-[8px] pl-2.5 pr-7 text-sm 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!"
className="h-[32px] rounded-[10px] pl-2.5 pr-7 text-[14px] leading-[18px] tracking-[0.01em] 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!"
onClick={() => {
navigate({
to: "/chat",
@ -425,7 +418,7 @@ export function AppSidebar() {
handleDeleteThread(item);
}}
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/recent-item:scale-100 group-hover/recent-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/recent-item:scale-100 group-hover/recent-item:opacity-100"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
@ -461,13 +454,13 @@ export function AppSidebar() {
>
<SidebarMenuButton
isActive={isActiveRun}
className="h-auto flex-col items-start gap-0.5 py-1.5 rounded-[8px] pl-2.5 pr-7 text-sm 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!"
className="h-auto flex-col items-start gap-0.5 py-1.5 rounded-[10px] pl-2.5 pr-7 text-[14px] tracking-[0.01em] 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!"
onClick={() => {
setSelectedHistoryRunId(run.id);
closeMobileIfOpen();
}}
>
<div className="flex w-full items-center gap-2">
<div className="flex w-full items-center gap-[8.5px]">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
@ -475,7 +468,7 @@ export function AppSidebar() {
)}
aria-hidden
/>
<span className="truncate text-sm">
<span className="truncate">
{run.model_name}
</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
@ -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"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
@ -516,7 +509,7 @@ export function AppSidebar() {
)}
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-t-0 group-data-[collapsible=icon]:px-0">
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-transparent group-data-[collapsible=icon]:px-0">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
@ -524,7 +517,7 @@ export function AppSidebar() {
<SidebarMenuButton
size="lg"
aria-label={`${displayTitle} account menu`}
className="!h-[50px] gap-[8px] rounded-[8px] text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec]! dark:hover:bg-[#2e3035]! hover:text-black! dark:hover:text-white! data-[state=open]:bg-[#ececec]! dark:data-[state=open]:bg-[#2e3035]! data-[state=open]:text-black! dark:data-[state=open]:text-white!"
className="!h-[50px] gap-[8px] rounded-[10px] text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-[state=open]:bg-[#f0f0f0]! dark:data-[state=open]:bg-[#2a2c2f]! data-[state=open]:text-black! dark:data-[state=open]:text-white!"
>
<div className="shrink-0">
<UserAvatar
@ -535,8 +528,8 @@ export function AppSidebar() {
/>
</div>
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
<span className="truncate font-heading text-[13px] font-semibold text-[#383835] dark:text-[#c7c7c4]">{displayTitle}</span>
<span className="truncate text-[11px] text-muted-foreground">Studio</span>
<span className="truncate font-heading text-[13px] tracking-[0.02em] font-semibold text-[#383835] dark:text-[#c7c7c4]">{displayTitle}</span>
<span className="truncate text-[11px] tracking-[0.01em] text-muted-foreground">Studio</span>
</div>
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
@ -544,24 +537,21 @@ export function AppSidebar() {
<DropdownMenuContent
side="top"
align="start"
className="w-[15rem] font-heading [&_[data-slot=dropdown-menu-item]]:rounded-[8px] [&_[data-slot=dropdown-menu-item]]:font-medium [&_[data-slot=dropdown-menu-item]]:text-[#383835] dark:[&_[data-slot=dropdown-menu-item]]:text-[#c7c7c4] [&_[data-slot=dropdown-menu-item]:focus]:bg-[#ececec] dark:[&_[data-slot=dropdown-menu-item]:focus]:bg-[#2e3035] [&_[data-slot=dropdown-menu-item]:focus]:text-black dark:[&_[data-slot=dropdown-menu-item]:focus]:text-white [&_[data-slot=dropdown-menu-item]:focus_*]:text-black! dark:[&_[data-slot=dropdown-menu-item]:focus_*]:text-white!"
className="w-[15rem] py-2.5 font-heading [&_[data-slot=dropdown-menu-group]]:flex [&_[data-slot=dropdown-menu-group]]:flex-col [&_[data-slot=dropdown-menu-group]]:gap-px [&_[data-slot=dropdown-menu-item]]:h-[32px] [&_[data-slot=dropdown-menu-item]]:px-2.5! [&_[data-slot=dropdown-menu-item]]:py-0! [&_[data-slot=dropdown-menu-item]]:gap-[8.5px]! [&_[data-slot=dropdown-menu-item]]:rounded-[10px] [&_[data-slot=dropdown-menu-item]]:font-medium [&_[data-slot=dropdown-menu-item]]:text-[14px] [&_[data-slot=dropdown-menu-item]]:leading-[18px] [&_[data-slot=dropdown-menu-item]]:tracking-[0.01em] [&_[data-slot=dropdown-menu-item]]:text-[#383835] dark:[&_[data-slot=dropdown-menu-item]]:text-[#c7c7c4] [&_[data-slot=dropdown-menu-item]_svg]:!size-[18px] [&_[data-slot=dropdown-menu-item]_svg]:shrink-0 [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f0f0f0] dark:[&_[data-slot=dropdown-menu-item]:focus]:bg-[#2a2c2f] [&_[data-slot=dropdown-menu-item]:focus]:text-black dark:[&_[data-slot=dropdown-menu-item]:focus]:text-white [&_[data-slot=dropdown-menu-item]:focus_*]:text-black! dark:[&_[data-slot=dropdown-menu-item]:focus_*]:text-white!"
>
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog()}
>
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-[18px]" />
<span>Settings</span>
<DropdownMenuShortcut>,</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
ref={anchorRef as React.Ref<HTMLDivElement>}
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
{isDark ? <Sun strokeWidth={1.75} className="size-[18px]" /> : <Moon strokeWidth={1.75} className="size-[18px]" />}
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
</DropdownMenuItem>
<DropdownMenuItem
@ -576,11 +566,11 @@ export function AppSidebar() {
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-[18px]" />
<span>Guided Tour</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<a
@ -588,7 +578,7 @@ export function AppSidebar() {
target="_blank"
rel="noopener noreferrer"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
<HugeiconsIcon icon={Book03Icon} strokeWidth={1.75} className="size-[18px]" />
<span>Learn More</span>
</a>
</DropdownMenuItem>
@ -600,7 +590,8 @@ export function AppSidebar() {
>
<HugeiconsIcon
icon={NewReleasesIcon}
className="size-4"
strokeWidth={1.75}
className="size-[18px]"
/>
<span>What's New</span>
</a>
@ -613,15 +604,16 @@ export function AppSidebar() {
>
<HugeiconsIcon
icon={MessageSearch01Icon}
className="size-4"
strokeWidth={1.75}
className="size-[18px]"
/>
<span>Feedback</span>
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
<HugeiconsIcon icon={PowerIcon} className="size-4" />
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-[18px]" />
<span>Shutdown</span>
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -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)",
}}
>
<ThreadPrimitive.Viewport
className={cn(
"aui-thread-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5",
hideComposer ? "pt-4" : "pt-[48px]",
)}
>
{!hideWelcome && (
<AuiIf condition={({ thread }) => thread.isEmpty && !thread.isLoading}>
<ThreadWelcome hideComposer={hideComposer} />
</AuiIf>
)}
<IntentAwareScrollProvider value={autoScrollContext}>
<ThreadPrimitive.Viewport
ref={viewportRef}
autoScroll={false}
scrollToBottomOnRunStart={false}
scrollToBottomOnInitialize={false}
scrollToBottomOnThreadSwitch={false}
className={cn(
"aui-thread-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5",
hideComposer ? "pt-4" : "pt-[48px]",
)}
>
{!hideWelcome && (
<AuiIf condition={({ thread }) => thread.isEmpty && !thread.isLoading}>
<ThreadWelcome hideComposer={hideComposer} />
</AuiIf>
)}
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage,
}}
/>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage,
}}
/>
{/* 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. */}
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
<div
className={cn("shrink-0", hideComposer ? "h-16" : "h-40")}
aria-hidden
/>
</AuiIf>
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
<ThreadPrimitive.ViewportFooter
className={cn(
"aui-thread-viewport-footer pointer-events-none sticky z-20 flex w-full justify-center bg-transparent",
hideComposer ? "bottom-3" : "bottom-[140px]",
)}
>
<ThreadScrollToBottom />
</ThreadPrimitive.ViewportFooter>
</AuiIf>
</ThreadPrimitive.Viewport>
{!hideComposer && (
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
<div
aria-hidden
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
className={cn("shrink-0", hideComposer ? "h-16" : "h-40")}
aria-hidden={true}
/>
<div className="relative px-5 pb-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
<ComposerAnimated disabled={isComposerAttachPending} />
</AuiIf>
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
<ThreadPrimitive.ViewportFooter
className={cn(
"aui-thread-viewport-footer pointer-events-none sticky z-20 flex w-full justify-center bg-transparent",
hideComposer ? "bottom-3" : "bottom-[140px]",
)}
>
<ThreadScrollToBottom />
</ThreadPrimitive.ViewportFooter>
</AuiIf>
</ThreadPrimitive.Viewport>
{!hideComposer && (
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
<div
aria-hidden={true}
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
/>
<div className="relative px-5 pb-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
<ComposerAnimated disabled={isComposerAttachPending} />
</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
LLMs can make mistakes. Double-check all responses.
</p>
</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
LLMs can make mistakes. Double-check all responses.
</p>
</div>
</div>
</AuiIf>
)}
</AuiIf>
)}
</IntentAwareScrollProvider>
</ThreadPrimitive.Root>
);
};
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 (
<ThreadPrimitive.ScrollToBottom asChild={true}>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
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",
)}
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
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"],
"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 (
<button
type="button"
onClick={() => {
if (!isDisabled && !isRunning) {
const store = useChatRuntimeStore.getState();
if (store.supportsReasoning) {
store.setReasoningEnabled(tools.includes("thinking"));
}
if (store.supportsTools) {
store.setToolsEnabled(tools.includes("search"));
store.setCodeToolsEnabled(tools.includes("code"));
}
aui.thread().append(prompt);
aui.composer().setText("");
return;
}
aui.composer().setText(prompt);
}}
className="fade-in slide-in-from-bottom-1 animate-in relative cursor-pointer corner-squircle rounded-xl border bg-background px-4 py-2.5 pr-12 text-left text-sm text-foreground shadow-sm transition-colors duration-150 hover:bg-accent"
>
<SuggestionPrimitive.Title />
{tools.length > 0 && (
<div className="absolute bottom-2.5 right-3 flex items-center gap-1">
{tools.map((tool) => {
const { icon: Icon, label } = toolIconMap[tool];
return (
<Icon
key={tool}
className="size-3 text-muted-foreground/60"
aria-label={label}
/>
);
})}
</div>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
onClick={() => 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",
)}
</button>
>
<ArrowDownIcon />
</TooltipIconButton>
);
};
@ -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 (
<div className="mx-auto flex w-full max-w-(--thread-max-width) items-center justify-center py-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@ -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 (
<div className="mb-2 flex w-full flex-row items-center gap-2 px-1.5 pt-0.5 pb-1">
<div className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs">
@ -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 (
<button
@ -463,7 +431,7 @@ const WebSearchToggle: FC = () => {
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
const disabled = !modelLoaded || !supportsTools;
const disabled = !(modelLoaded && supportsTools);
return (
<button
@ -492,10 +460,8 @@ const CodeToolsToggle: FC = () => {
);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
const setCodeToolsEnabled = useChatRuntimeStore(
(s) => s.setCodeToolsEnabled,
);
const disabled = !modelLoaded || !supportsTools;
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
const disabled = !(modelLoaded && supportsTools);
return (
<button
@ -510,7 +476,9 @@ const CodeToolsToggle: FC = () => {
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
)}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
aria-label={
codeToolsEnabled ? "Disable code execution" : "Enable code execution"
}
>
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
@ -523,6 +491,11 @@ const ToolStatusDisplay: FC = () => {
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const [elapsed, setElapsed] = useState(0);
const [visible, setVisible] = useState(false);
const visibleRef = useRef(false);
useEffect(() => {
visibleRef.current = visible;
}, [visible]);
useEffect(() => {
if (!toolStatus) {
@ -540,7 +513,7 @@ const ToolStatusDisplay: FC = () => {
// tools show immediately so the badge does not flicker. Fast
// tool calls that all complete under 300ms never show the badge.
let showTimer: ReturnType<typeof setTimeout> | undefined;
if (!visible) {
if (!visibleRef.current) {
showTimer = setTimeout(() => setVisible(true), 300);
}
@ -549,11 +522,15 @@ const ToolStatusDisplay: FC = () => {
}, 1000);
return () => {
clearInterval(interval);
if (showTimer) clearTimeout(showTimer);
if (showTimer) {
clearTimeout(showTimer);
}
};
}, [toolStatus, isThreadRunning]);
if (!toolStatus || !visible) return null;
if (!(toolStatus && visible)) {
return null;
}
const isRunning = toolStatus.startsWith("Running");
const StatusIcon = isRunning ? TerminalIcon : GlobeIcon;
return (
@ -649,7 +626,9 @@ const GeneratingIndicator: FC = () => {
({ message }) =>
message.content.length === 0 && message.status?.type === "running",
);
if (!show) return null;
if (!show) {
return null;
}
return <span className="text-sm text-muted-foreground">Generating...</span>;
};
@ -736,7 +715,9 @@ const CopyButton: FC = () => {
const text = aui.message().getCopyText();
if (await copyToClipboard(text)) {
setCopied(true);
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current);
}
resetTimeoutRef.current = setTimeout(() => {
setCopied(false);
resetTimeoutRef.current = null;
@ -794,8 +775,12 @@ const AssistantActionBar: FC = () => {
};
const UserMessageAudio: FC = () => {
const audioName = useAuiState(({ message }) => sentAudioNames.get(message.id));
if (!audioName) return null;
const audioName = useAuiState(({ message }) =>
sentAudioNames.get(message.id),
);
if (!audioName) {
return null;
}
return (
<div className="col-start-2 flex justify-end">
<div className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs">

View file

@ -0,0 +1,451 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { useAuiEvent } from "@assistant-ui/react";
import {
type ReactNode,
type RefCallback,
createContext,
useCallback,
useContext,
useMemo,
useRef,
useSyncExternalStore,
} from "react";
/**
* Intent-aware autoscroll for a Thread viewport.
*
* Why we don't reuse assistant-ui's built-in autoscroll:
* `useThreadViewportAutoScroll` runs unconditionally whenever
* `ThreadPrimitive.Viewport` is mounted. Even with every
* `scrollToBottomOn*` prop disabled, it still installs observers
* that write `isAtBottom` to the shared viewport store on every
* layout change. On sidebar toggles, browser resizes, or
* mobiledesktop breakpoint crossings, that write races our scroll
* correction whoever writes last wins, and the scroll-to-bottom
* button flickers or sticks depending on observer ordering.
*
* Strategy:
* - Own `isAtBottom` as local state (exposed via
* `useIsThreadAtBottom`). Upstream can still write to its own
* store; nobody reads from it.
* - Drive the viewport with a single rAF loop governed by a follow
* deadline (`followUntilRef`). Any signal that can invalidate
* bottom alignment (resize, mutation, AUI event, programmatic
* scroll) extends the deadline; the loop pins to the bottom and
* reports `isAtBottom=true` until it expires, then settles on
* pure DOM observation.
* - Detect user intent (wheel up, touch swipe up, scroll direction)
* to detach. While detached, resize/mutation signals don't extend
* the deadline, so the button appears and stays. Re-attach when
* the user scrolls *down* within 24px of the bottom.
*/
// 2px, not 1: subpixel rounding on HiDPI displays can leave a fractional
// gap at the "true" bottom that a 1px threshold reports as not-at-bottom.
const AT_BOTTOM_THRESHOLD_PX = 2;
const RE_ATTACH_THRESHOLD_PX = 24;
const TOUCH_MOVE_THRESHOLD_PX = 4;
// Cumulative upward movement (summed across scroll events) that
// counts as a deliberate detach. Summed rather than per-event so that
// slow 1-px-per-event sources — middle-click autoscroll, scrollbar
// drags, some trackpads — accumulate instead of slipping under a
// per-event threshold forever.
const UPWARD_DETACH_THRESHOLD_PX = 2;
// Window during which the viewport pins to the bottom through
// layout/content races. Extends on every resize/mutation, so streaming
// keeps the viewport pinned as long as content keeps arriving; settles
// this long after the last change.
const FOLLOW_SETTLE_MS = 600;
export type ScrollToBottom = (behavior?: ScrollBehavior) => void;
type AutoScrollContextValue = {
scrollToBottom: ScrollToBottom;
getIsAtBottom: () => boolean;
subscribe: (listener: () => void) => () => void;
};
const noopContext: AutoScrollContextValue = {
scrollToBottom: () => {
/* no viewport mounted */
},
getIsAtBottom: () => true,
subscribe: () => () => {
/* no-op */
},
};
const AutoScrollContext = createContext<AutoScrollContextValue>(noopContext);
export function IntentAwareScrollProvider({
value,
children,
}: {
value: AutoScrollContextValue;
children: ReactNode;
}) {
return (
<AutoScrollContext.Provider value={value}>
{children}
</AutoScrollContext.Provider>
);
}
export function useScrollThreadToBottom(): ScrollToBottom {
return useContext(AutoScrollContext).scrollToBottom;
}
export function useIsThreadAtBottom(): boolean {
const ctx = useContext(AutoScrollContext);
return useSyncExternalStore(ctx.subscribe, ctx.getIsAtBottom, () => true);
}
export function useIntentAwareAutoScroll(): {
ref: RefCallback<HTMLElement>;
context: AutoScrollContextValue;
} {
const cleanupRef = useRef<(() => void) | null>(null);
const userDetachedRef = useRef(false);
const followUntilRef = useRef(0);
const isAtBottomRef = useRef(true);
const listenersRef = useRef<Set<() => void>>(new Set());
const scrollImplRef = useRef<ScrollToBottom>(() => {
/* no viewport mounted */
});
const getIsAtBottom = useCallback(() => isAtBottomRef.current, []);
const subscribe = useCallback((listener: () => void) => {
listenersRef.current.add(listener);
return () => {
listenersRef.current.delete(listener);
};
}, []);
const setIsAtBottom = useCallback((value: boolean) => {
if (isAtBottomRef.current === value) {
return;
}
isAtBottomRef.current = value;
for (const listener of listenersRef.current) {
listener();
}
}, []);
const scrollToBottom = useCallback<ScrollToBottom>((behavior) => {
scrollImplRef.current(behavior);
}, []);
const attach = useCallback(
(el: HTMLElement) => {
let rafId: number | null = null;
let lastScrollTop = el.scrollTop;
let lastClientWidth = el.clientWidth;
let lastClientHeight = el.clientHeight;
let upwardAccumulator = 0;
let touchStartY = 0;
const distanceFromBottom = (): number => {
if (el.scrollHeight <= el.clientHeight) {
return 0;
}
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
let lastDistanceFromBottom = distanceFromBottom();
const atBottomStrict = (): boolean =>
distanceFromBottom() <= AT_BOTTOM_THRESHOLD_PX;
// True only when there is room to scroll upward. Guards the
// wheel/touch detach paths: a wheel-up or swipe-down gesture on
// a viewport with nothing above (short thread, or already at
// the top) can't express intent to leave the bottom and must
// not flip userDetachedRef — otherwise later streaming updates
// skip extendFollow and auto-follow stays dead for the session.
const canScrollUp = (): boolean => el.scrollTop > 0;
// True when a nested scrollable ancestor of the event target
// (e.g. the reasoning panel's own overflow-y-auto region, or
// any tool output with internal scroll) has room above and
// will therefore consume the upward delta before it reaches
// the viewport. Walking stops at the viewport element itself,
// so only intermediate inner scrollers count.
//
// Wheel and touchmove events bubble, so without this check a
// user reading back through a long reasoning pane mid-stream
// would falsely detach the outer viewport.
const innerScrollWillConsumeUpward = (
target: EventTarget | null,
): boolean => {
let node =
target instanceof Element ? (target as Element | null) : null;
while (node && node !== el) {
if (node.scrollTop > 0) {
const { overflowY } = window.getComputedStyle(node);
if (
overflowY === "auto" ||
overflowY === "scroll" ||
overflowY === "overlay"
) {
return true;
}
}
node = node.parentElement;
}
return false;
};
const extendFollow = (): void => {
if (userDetachedRef.current) {
return;
}
followUntilRef.current = performance.now() + FOLLOW_SETTLE_MS;
};
const detach = (): void => {
userDetachedRef.current = true;
followUntilRef.current = 0;
};
const requestTick = (): void => {
if (rafId === null) {
rafId = requestAnimationFrame(tick);
}
};
// Single rAF loop. While within the follow window and not
// detached, pin the viewport to the bottom and report
// isAtBottom=true every frame. Otherwise settle on whatever the
// DOM says. Scheduling is edge-triggered: scroll/resize/mutation
// events call requestTick(), and the loop self-perpetuates only
// as long as pinning is still active.
const tick = (): void => {
rafId = null;
const following =
!userDetachedRef.current &&
performance.now() < followUntilRef.current;
if (following) {
if (!atBottomStrict() && el.scrollHeight > el.clientHeight) {
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
}
setIsAtBottom(true);
requestTick();
return;
}
setIsAtBottom(atBottomStrict());
};
scrollImplRef.current = (behavior = "auto") => {
userDetachedRef.current = false;
followUntilRef.current = performance.now() + FOLLOW_SETTLE_MS;
if (el.scrollHeight > el.clientHeight) {
el.scrollTo({ top: el.scrollHeight, behavior });
}
setIsAtBottom(true);
requestTick();
};
const onWheel = (e: WheelEvent) => {
if (
e.deltaY < 0 &&
canScrollUp() &&
!innerScrollWillConsumeUpward(e.target)
) {
detach();
}
};
const onTouchStart = (e: TouchEvent) => {
touchStartY = e.touches[0]?.clientY ?? 0;
};
const onTouchMove = (e: TouchEvent) => {
const y = e.touches[0]?.clientY ?? 0;
// Finger moves DOWN on the screen = content scrolls UP.
if (
y - touchStartY > TOUCH_MOVE_THRESHOLD_PX &&
canScrollUp() &&
!innerScrollWillConsumeUpward(e.target)
) {
detach();
}
};
const onScroll = () => {
const scrollTop = el.scrollTop;
const clientWidth = el.clientWidth;
const clientHeight = el.clientHeight;
const sizeChanged =
clientWidth !== lastClientWidth || clientHeight !== lastClientHeight;
const delta = scrollTop - lastScrollTop;
const distanceNow = distanceFromBottom();
// Viewport resizes can clamp scrollTop and produce spurious
// direction signals. Only flip intent on deliberate scrolls.
if (sizeChanged) {
upwardAccumulator = 0;
} else if (delta > 0) {
// Downward: reset the upward accumulator, and re-attach when
// the user has scrolled back within range of the bottom.
upwardAccumulator = 0;
if (
userDetachedRef.current &&
distanceNow <= RE_ATTACH_THRESHOLD_PX
) {
userDetachedRef.current = false;
extendFollow();
}
} else if (delta < 0 && !userDetachedRef.current) {
// Upward: sum across events. Middle-click autoscroll and
// some trackpads deliver 1px-per-event scrolls that each
// slip under a per-event threshold; summing catches them.
//
// Count distance-from-bottom growth, not raw scrollTop
// delta. When content above collapses (reasoning panels
// auto-closing after streaming, tool outputs auto-hiding),
// browsers scroll-anchor to keep visible content stable:
// scrollTop decreases but scrollHeight decreases by the
// same amount, so distance is unchanged. Those layout-
// induced deltas must not flip user intent.
const distanceDelta = distanceNow - lastDistanceFromBottom;
if (distanceDelta > 0) {
upwardAccumulator += distanceDelta;
if (upwardAccumulator >= UPWARD_DETACH_THRESHOLD_PX) {
detach();
upwardAccumulator = 0;
}
}
}
lastScrollTop = scrollTop;
lastClientWidth = clientWidth;
lastClientHeight = clientHeight;
lastDistanceFromBottom = distanceNow;
requestTick();
};
const resizeObserver = new ResizeObserver(() => {
extendFollow();
requestTick();
});
const mutationObserver = new MutationObserver(() => {
extendFollow();
requestTick();
});
const onViewportResize = () => {
extendFollow();
requestTick();
};
// Fresh attach always starts pinned. `userDetachedRef` survives
// ref rebinds (it's hook-scoped), so if the viewport element is
// ever unmounted and remounted without an AUI lifecycle event
// (e.g. a parent layout refactor that remounts the viewport),
// a prior detach would silently disable auto-follow for the
// rest of the session.
userDetachedRef.current = false;
// Pin to bottom when the ref first attaches. Covers the case
// where `thread.initialize` fires before the ref is bound.
extendFollow();
if (el.scrollHeight > el.clientHeight) {
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
}
setIsAtBottom(true);
requestTick();
resizeObserver.observe(el);
mutationObserver.observe(el, {
childList: true,
subtree: true,
characterData: true,
// Layout-affecting attributes only. Excludes `style`, which
// elements may mutate in response to viewport state (feedback
// loop). `class` catches Tailwind show/hide; `hidden` /
// `aria-hidden` / `aria-expanded` / `data-state` catch Radix
// and native collapsible toggles that change scrollHeight
// without triggering the viewport ResizeObserver.
attributes: true,
attributeFilter: [
"class",
"hidden",
"aria-hidden",
"aria-expanded",
"data-state",
],
});
el.addEventListener("wheel", onWheel, { passive: true });
el.addEventListener("touchstart", onTouchStart, { passive: true });
el.addEventListener("touchmove", onTouchMove, { passive: true });
el.addEventListener("scroll", onScroll, { passive: true });
// ResizeObserver above covers browser-window resizes (they resize
// the viewport element). visualViewport.resize is the only signal
// for iOS software-keyboard changes, where the visual viewport
// shrinks without the viewport element's clientHeight changing.
window.visualViewport?.addEventListener("resize", onViewportResize);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
resizeObserver.disconnect();
mutationObserver.disconnect();
el.removeEventListener("wheel", onWheel);
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("touchmove", onTouchMove);
el.removeEventListener("scroll", onScroll);
window.visualViewport?.removeEventListener("resize", onViewportResize);
scrollImplRef.current = () => {
/* no viewport mounted */
};
};
},
[setIsAtBottom],
);
// Thread lifecycle moments that always pin to the bottom, regardless
// of prior detach state. "auto" respects CSS smooth scrolling for
// runStart so new turns glide in; "instant" snaps for load/switch
// where any animation would just be wasted motion.
const pinToBottom = useCallback((behavior: ScrollBehavior) => {
userDetachedRef.current = false;
scrollImplRef.current(behavior);
}, []);
useAuiEvent("thread.runStart", () => pinToBottom("auto"));
useAuiEvent("thread.initialize", () => pinToBottom("instant"));
useAuiEvent("threadListItem.switchedTo", () => pinToBottom("instant"));
const ref = useCallback<RefCallback<HTMLElement>>(
(el) => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
if (el) {
cleanupRef.current = attach(el);
}
},
[attach],
);
const context = useMemo<AutoScrollContextValue>(
() => ({ scrollToBottom, getIsAtBottom, subscribe }),
[scrollToBottom, getIsAtBottom, subscribe],
);
return { ref, context };
}

View file

@ -518,7 +518,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("gap-0.5 flex w-full min-w-0 flex-col", className)}
className={cn("gap-px flex w-full min-w-0 flex-col", className)}
{...props}
/>
)
@ -536,7 +536,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm cursor-pointer transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:w-full! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-2! data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate group-data-[collapsible=icon]:[&>span]:hidden [&_svg]:size-4 [&_svg]:shrink-0 group-data-[collapsible=icon]:[&_svg]:size-[18px]",
"ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm cursor-pointer group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:w-full! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-2! data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate group-data-[collapsible=icon]:[&>span]:hidden [&_svg]:size-4 [&_svg]:shrink-0 group-data-[collapsible=icon]:[&_svg]:size-[18px]",
{
variants: {
variant: {

View file

@ -10,14 +10,9 @@ import { Thread } from "@/components/assistant-ui/thread";
import { cn } from "@/lib/utils";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { useSidebar } from "@/components/ui/sidebar";
import {
Settings05Icon,
} from "@hugeicons/core-free-icons";
import { Settings05Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
@ -351,7 +346,7 @@ function GeneralCompareHeader({
return (
<div
className={cn(
"flex h-[48px] shrink-0 items-center gap-2 bg-background",
"flex h-[48px] shrink-0 items-start pt-[11px] gap-2 bg-background",
side === "left" ? "pl-12 pr-3 md:pl-2" : "pl-3 pr-12",
)}
>
@ -564,8 +559,7 @@ export function ChatPage(): ReactElement {
if (search.compare) {
return {
mode: "compare",
pairId:
search.compare,
pairId: search.compare,
};
}
if (search.thread) {
@ -658,8 +652,14 @@ export function ChatPage(): ReactElement {
},
[modelSelectorLocked],
);
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
const openSettings = useCallback(
() => setSettingsOpen(true),
[setSettingsOpen],
);
const closeSettings = useCallback(
() => setSettingsOpen(false),
[setSettingsOpen],
);
const { setPinned, isMobile } = useSidebar();
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
@ -686,7 +686,9 @@ export function ChatPage(): ReactElement {
.first()
.then((msg) => {
const metadata = msg?.metadata as Record<string, unknown> | undefined;
const usage = metadata?.contextUsage as ReturnType<typeof useChatRuntimeStore.getState>["contextUsage"];
const usage = metadata?.contextUsage as ReturnType<
typeof useChatRuntimeStore.getState
>["contextUsage"];
if (usage) useChatRuntimeStore.getState().setContextUsage(usage);
});
}
@ -865,7 +867,8 @@ export function ChatPage(): ReactElement {
className={cn(
"absolute top-0 left-0 right-[10px] z-30 flex h-[48px] shrink-0 items-start pt-[11px] pr-2 bg-background",
isMobile ? "pl-12 pr-1.5" : "pl-2",
view.mode === "compare" && "right-[10px] left-auto w-auto bg-transparent pl-0 pr-2",
view.mode === "compare" &&
"right-[10px] left-auto w-auto bg-transparent pl-0 pr-2",
)}
>
<div className="flex items-center gap-1">

View file

@ -1271,7 +1271,7 @@ export function ChatSettingsPanel({
return (
<aside
className={`relative z-50 shrink-0 h-full overflow-hidden bg-muted/70 transition-[width] duration-200 ease-linear ${open ? "w-[17rem]" : "w-0"}`}
className={`relative z-50 shrink-0 h-full overflow-hidden bg-muted/70 ${open ? "w-[17rem]" : "w-0"}`}
>
<div className="h-full w-[17rem]">{settingsContent}</div>
</aside>

View file

@ -281,6 +281,22 @@
}
}
--animate-icon-pop: icon-pop 0.3s ease-out;
@keyframes icon-pop {
0% {
transform: scale(1);
}
45% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}
--animate-shine: shine var(--duration) infinite linear;
@keyframes shine {
@ -306,6 +322,9 @@
body {
@apply font-sans bg-background text-foreground;
letter-spacing: var(--tracking-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
html {
@ -334,18 +353,15 @@
@layer utilities {
/* Heading font utility matches the sidebar logo's letter-spacing so
all Hellix text (select model, menus, nav items) stays visually
consistent, with a small bump in dark mode to offset optical bloom. */
/* Heading font utility the logo applies its own dark-mode tracking
to offset optical bloom at large sizes. For small UI text (menus,
nav items) we keep a single tight tracking in both themes, relying
on global antialiased font-smoothing to neutralize the bloom. */
.font-heading {
font-family: var(--font-heading);
letter-spacing: -0.01em;
}
.dark .font-heading {
letter-spacing: 0.02em;
}
/* Elevated surface shadow (use ring-* for borders) */
.shadow-border {
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);