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:
parent
4c53191de9
commit
92d43a4f68
7 changed files with 671 additions and 224 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* mobile↔desktop 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 };
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue