{source.title || domain}
-{domain}
+{domain}
{source.description && ( -+
{source.description}
)} @@ -245,7 +250,7 @@ const SourcesGroup: FC = () => { const hiddenCount = sources.length - (visibleCount ?? sources.length); return ( -- LLMs can make mistakes. Double-check all responses. +
+ LLMs can make mistakes. Double-check responses.
with highlight spans), the block's rendered height
+ // briefly dips and then recovers a frame later. That dip shrinks
+ // `scrollHeight`, which the browser handles by *synchronously*
+ // capping `scrollTop` to the new (smaller) `scrollHeight −
+ // clientHeight`. The cap is visible as a one-frame upward jump;
+ // the recovery a frame or two later is the "snap back" the user
+ // perceives as a flicker. No amount of programmatic re-scrolling
+ // can prevent this — once `scrollHeight` drops, the cap has
+ // already happened and `scrollTop` cannot be pushed past the new
+ // max.
+ //
+ // Fix: keep `scrollHeight` monotonic across the follow window.
+ // We track the maximum *content* height (scrollHeight minus our
+ // own padding contribution) seen during follow, and compensate
+ // for any shortfall by writing the deficit into a CSS custom
+ // property `--aui-scroll-stabilizer`, which the viewport's
+ // `padding-bottom` reads. A 5px content shrink instantly grows
+ // the padding by 5px, so the browser sees no scrollHeight change
+ // and never caps scrollTop. As content naturally grows past its
+ // prior high-water mark (e.g. the next message streams in), the
+ // padding shrinks back toward zero.
+ //
+ // Self-contained: lives entirely on the viewport element via a
+ // CSS variable. Doesn't touch the composer, the action bar, the
+ // message footer, the spacer, or any other UI.
+ //
+ // Returns the post-adjustment scrollHeight so a single layout
+ // read per observer callback can feed both stabilization and
+ // pinning, avoiding a redundant flush.
+ const stabilize = (): number => {
+ const sh = el.scrollHeight;
+ const currentContent = sh - stabilizerPx;
+ const followActive =
+ !userDetachedRef.current &&
+ performance.now() < followUntilRef.current;
+ if (!followActive) {
+ // Outside the follow window we stop adjusting, but we keep
+ // `maxContentHeight` aligned with reality so the next follow
+ // session starts from the current content size, not stale.
+ maxContentHeight = currentContent;
+ return sh;
+ }
+ if (currentContent > maxContentHeight) {
+ maxContentHeight = currentContent;
+ }
+ const shrink = maxContentHeight - currentContent;
+ // Large shrinks (over STABILIZER_MAX_PX) are intentional content
+ // removals — message delete, regenerate clearing the old
+ // assistant turn, reasoning-panel collapse. Compensating for
+ // those would leave persistent empty space at the bottom of the
+ // viewport, which the user reads as "weird empty gap." Release
+ // the stabilizer instead and rebase the high-water mark; the
+ // pinIfFollowing call right after will smoothly re-anchor to
+ // the new (smaller) bottom.
+ if (shrink > STABILIZER_MAX_PX) {
+ maxContentHeight = currentContent;
+ if (stabilizerPx !== 0) {
+ stabilizerPx = 0;
+ el.style.removeProperty("--aui-scroll-stabilizer");
+ }
+ return currentContent;
+ }
+ const needed = Math.max(0, shrink);
+ if (needed !== stabilizerPx) {
+ stabilizerPx = needed;
+ el.style.setProperty(
+ "--aui-scroll-stabilizer",
+ `${stabilizerPx}px`,
+ );
+ }
+ return currentContent + stabilizerPx;
+ };
- const mutationObserver = new MutationObserver(() => {
- extendFollow();
- requestTick();
- });
+ // Synchronous pin-to-bottom. Observer callbacks run in the event-
+ // loop's "update the rendering" step (after layout, before paint),
+ // so the scrollTo here is composited in the same frame as the
+ // mutation that triggered the observer.
+ const pinIfFollowing = (scrollHeight: number): void => {
+ if (userDetachedRef.current) {
+ return;
+ }
+ if (performance.now() >= followUntilRef.current) {
+ return;
+ }
+ if (scrollHeight <= el.clientHeight) {
+ return;
+ }
+ el.scrollTo({ top: scrollHeight, behavior: "instant" });
+ };
- const onViewportResize = () => {
+ // All three layout-change signals fan in here so there's a
+ // single place to understand "what runs when the viewport's
+ // content shape changes". Order matters: extend first so the
+ // stabilizer sees the follow window as active; stabilize before
+ // pinning so we scroll to the post-adjustment scrollHeight.
+ const onLayoutChange = (): void => {
extendFollow();
+ const scrollHeight = stabilize();
+ pinIfFollowing(scrollHeight);
requestTick();
};
+ const resizeObserver = new ResizeObserver(onLayoutChange);
+ const mutationObserver = new MutationObserver(onLayoutChange);
+ const onViewportResize = onLayoutChange;
+
// 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
@@ -366,7 +493,13 @@ export function useIntentAwareAutoScroll(): {
setIsAtBottom(true);
requestTick();
- resizeObserver.observe(el);
+ // Observe the border box, not the content box. The stabilizer
+ // writes `padding-bottom`, which shrinks the content box; if we
+ // observed that, every stabilizer adjustment would echo back as
+ // a resize and re-enter onLayoutChange. Border-box stays put
+ // through padding changes but still tracks parent-driven
+ // resizes (window, sidebar toggle) — which is all we need.
+ resizeObserver.observe(el, { box: "border-box" });
mutationObserver.observe(el, {
childList: true,
subtree: true,
diff --git a/studio/frontend/src/components/ui/button.tsx b/studio/frontend/src/components/ui/button.tsx
index e95ab8faa7..9e27446989 100644
--- a/studio/frontend/src/components/ui/button.tsx
+++ b/studio/frontend/src/components/ui/button.tsx
@@ -1,69 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-/* eslint-disable react-refresh/only-export-components */
-
-import { type VariantProps, cva } from "class-variance-authority";
-import { Slot } from "radix-ui";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-export const buttonVariants = cva(
- "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
- {
- variants: {
- variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/80",
- dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
- outline:
- "border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
- ghost:
- "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
- destructive:
- "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default:
- "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
- xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
- icon: "size-9",
- "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
- "icon-sm": "size-8",
- "icon-lg": "size-10",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- },
-);
-
-export function Button({
- className,
- variant = "default",
- size = "default",
- asChild = false,
- ...props
-}: React.ComponentProps<"button"> &
- VariantProps & {
- asChild?: boolean;
- }): React.ReactElement {
- const Comp = asChild ? Slot.Root : "button";
-
- return (
-
- );
-}
+/* eslint-disable react-refresh/only-export-components */
+
+import { type VariantProps, cva } from "class-variance-authority";
+import { Slot } from "radix-ui";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export const buttonVariants = cva(
+ "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
+ outline:
+ "border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+ destructive:
+ "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
+ xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ icon: "size-9",
+ "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }): React.ReactElement {
+ const Comp = asChild ? Slot.Root : "button";
+
+ return (
+
+ );
+}
diff --git a/studio/frontend/src/components/ui/select.tsx b/studio/frontend/src/components/ui/select.tsx
index f65d7c3676..4044c164e5 100644
--- a/studio/frontend/src/components/ui/select.tsx
+++ b/studio/frontend/src/components/ui/select.tsx
@@ -1,244 +1,257 @@
// 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 { Select as SelectPrimitive } from "radix-ui";
-import type * as React from "react";
-import { createContext, useContext, useState } from "react";
-
-import { cn } from "@/lib/utils";
-import { useDialogPortalContainer } from "@/components/ui/dialog";
-import {
- ArrowDown01Icon,
- ArrowUp01Icon,
- Tick02Icon,
- UnfoldMoreIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-
-const SelectOpenContext = createContext(false);
-
-function Select({
- onOpenChange,
- ...props
-}: React.ComponentProps) {
- const [isOpen, setIsOpen] = useState(false);
- return (
-
- {
- setIsOpen(open);
- onOpenChange?.(open);
- }}
- {...props}
- />
-
- );
-}
-
-function SelectGroup({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function SelectValue({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function SelectTrigger({
- className,
- size = "default",
- children,
- ...props
-}: React.ComponentProps & {
- size?: "sm" | "default";
-}) {
- const isOpen = useContext(SelectOpenContext);
-
- return (
-
- {children}
-
-
-
-
- );
-}
-
-function SelectContent({
- className,
- children,
- position = "item-aligned",
- align = "center",
- container,
- ...props
-}: React.ComponentProps & {
- container?: HTMLElement | null;
-}) {
- const dialogContainer = useDialogPortalContainer();
- return (
-
-
-
-
- {children}
-
-
-
-
- );
-}
-
-function SelectLabel({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function SelectItem({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
- );
-}
-
-function SelectSeparator({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function SelectScrollUpButton({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- );
-}
-
-function SelectScrollDownButton({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- );
-}
-
-export {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectLabel,
- SelectScrollDownButton,
- SelectScrollUpButton,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-};
+"use client";
+
+import { Select as SelectPrimitive } from "radix-ui";
+import type * as React from "react";
+import { createContext, useContext, useState } from "react";
+
+import { cn } from "@/lib/utils";
+import { useDialogPortalContainer } from "@/components/ui/dialog";
+import {
+ ArrowDown01Icon,
+ ArrowUp01Icon,
+ Tick02Icon,
+ UnfoldMoreIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+const SelectOpenContext = createContext(false);
+
+function Select({
+ onOpenChange,
+ ...props
+}: React.ComponentProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ return (
+
+ {
+ setIsOpen(open);
+ onOpenChange?.(open);
+ }}
+ {...props}
+ />
+
+ );
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ icon,
+ iconClassName,
+ animateRadius = true,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default";
+ icon?: typeof UnfoldMoreIcon;
+ iconClassName?: string;
+ animateRadius?: boolean;
+}) {
+ const isOpen = useContext(SelectOpenContext);
+
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ container,
+ ...props
+}: React.ComponentProps & {
+ container?: HTMLElement | null;
+}) {
+ const dialogContainer = useDialogPortalContainer();
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/studio/frontend/src/components/ui/sidebar.tsx b/studio/frontend/src/components/ui/sidebar.tsx
index 8eb8c51491..6be77d01b9 100644
--- a/studio/frontend/src/components/ui/sidebar.tsx
+++ b/studio/frontend/src/components/ui/sidebar.tsx
@@ -1,768 +1,770 @@
-// 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 * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
-import { Slot } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Separator } from "@/components/ui/separator"
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Skeleton } from "@/components/ui/skeleton"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { useIsMobile } from "@/hooks/use-mobile"
-import { HugeiconsIcon } from "@hugeicons/react"
-import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
-
-const noop = () => {}
-
-const SIDEBAR_WIDTH = "16rem"
-const SIDEBAR_WIDTH_ICON = "3rem"
-const SIDEBAR_KEYBOARD_SHORTCUT = "b"
-
-type SidebarContextProps = {
- state: "expanded" | "collapsed"
- open: boolean
- setOpen: (open: boolean) => void
- openMobile: boolean
- setOpenMobile: (open: boolean) => void
- isMobile: boolean
- toggleSidebar: () => void
- hasPinMode: boolean
- pinned: boolean
- setPinned: (value: boolean) => void
- togglePinned: () => void
-}
-
-const SidebarContext = React.createContext(null)
-
-function useSidebar() {
- const context = React.useContext(SidebarContext)
- if (!context) {
- throw new Error("useSidebar must be used within a SidebarProvider.")
- }
-
- return context
-}
-
-function SidebarProvider({
- defaultOpen = true,
- open: openProp,
- onOpenChange: setOpenProp,
- pinned: pinnedProp,
- setPinned: setPinnedProp,
- togglePinned: togglePinnedProp,
- className,
- style,
- children,
- ...props
-}: React.ComponentProps<"div"> & {
- defaultOpen?: boolean
- open?: boolean
- onOpenChange?: (open: boolean) => void
- pinned?: boolean
- setPinned?: (value: boolean) => void
- togglePinned?: () => void
-}) {
- const isMobile = useIsMobile()
- const [openMobile, setOpenMobile] = React.useState(false)
-
- const prevIsMobileRef = React.useRef(isMobile)
- React.useEffect(() => {
- if (prevIsMobileRef.current && !isMobile) {
- setOpenMobile(false)
- }
- prevIsMobileRef.current = isMobile
- }, [isMobile])
-
- // Whether pin mode is active (caller provides pinned + setPinned + togglePinned).
- const hasPinMode = pinnedProp !== undefined && setPinnedProp !== undefined && togglePinnedProp !== undefined
-
- // This is the internal state of the sidebar.
- // We use openProp and setOpenProp for control from outside the component.
- const [_open, _setOpen] = React.useState(defaultOpen)
-
- // When pin mode is active, open is driven entirely by `pinned` (explicit
- // user toggle). Otherwise fall back to the controlled/uncontrolled pattern.
- const open = hasPinMode ? !!pinnedProp : (openProp ?? _open)
-
- const setOpen = React.useCallback(
- (value: boolean | ((value: boolean) => boolean)) => {
- const openState = typeof value === "function" ? value(open) : value
-
- if (hasPinMode) {
- // In pin mode, setOpen controls pinned state.
- setPinnedProp?.(openState)
- return
- }
-
- if (setOpenProp) {
- setOpenProp(openState)
- } else {
- _setOpen(openState)
- }
- },
- [setOpenProp, open, hasPinMode, setPinnedProp]
- )
-
- // Helper to toggle the sidebar.
- const toggleSidebar = React.useCallback(() => {
- if (isMobile) return setOpenMobile((open) => !open)
- if (hasPinMode && togglePinnedProp) return togglePinnedProp()
- return setOpen((open) => !open)
- }, [isMobile, setOpen, setOpenMobile, hasPinMode, togglePinnedProp])
-
- // Adds a keyboard shortcut to toggle the sidebar.
- React.useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (
- event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
- (event.metaKey || event.ctrlKey)
- ) {
- event.preventDefault()
- toggleSidebar()
- }
- }
-
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [toggleSidebar])
-
- // We add a state so that we can do data-state="expanded" or "collapsed".
- // This makes it easier to style the sidebar with Tailwind classes.
- const state = open ? "expanded" : "collapsed"
-
- const pinned = pinnedProp ?? false
- const setPinned = setPinnedProp ?? noop
- const togglePinned = togglePinnedProp ?? noop
-
- const contextValue = React.useMemo(
- () => ({
- state,
- open,
- setOpen,
- isMobile,
- openMobile,
- setOpenMobile,
- toggleSidebar,
- hasPinMode,
- pinned,
- setPinned,
- togglePinned,
- }),
- [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned]
- )
-
- return (
-
-
- {children}
-
-
- )
-}
-
-function Sidebar({
- side = "left",
- variant = "sidebar",
- collapsible = "offcanvas",
- className,
- children,
- dir,
- ...props
-}: React.ComponentProps<"div"> & {
- side?: "left" | "right"
- variant?: "sidebar" | "floating" | "inset"
- collapsible?: "offcanvas" | "icon" | "none"
-}) {
- const { isMobile, state, openMobile, setOpenMobile, hasPinMode, pinned } = useSidebar()
-
- if (collapsible === "none") {
- return (
-
- {children}
-
- )
- }
-
- if (isMobile) {
- return (
-
-
-
- Sidebar
- Displays the mobile sidebar.
-
- {children}
-
-
- )
- }
-
- return (
-
- {/* This is what handles the sidebar gap on desktop */}
-
-
-
- {children}
-
-
-
- )
-}
-
-function SidebarTrigger({
- className,
- onClick,
- ...props
-}: React.ComponentProps) {
- const { toggleSidebar } = useSidebar()
-
- return (
- {
- onClick?.(event)
- toggleSidebar()
- }}
- {...props}
- >
-
- Toggle Sidebar
-
- )
-}
-
-function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
- const { toggleSidebar } = useSidebar()
-
- return (
-
- )
-}
-
-function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
- return (
-
- )
-}
-
-function SidebarInput({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarSeparator({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
- return (
- *]:shrink-0",
- className
- )}
- {...props}
- />
- )
-}
-
-function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarGroupLabel({
- className,
- asChild = false,
- ...props
-}: React.ComponentProps<"div"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot.Root : "div"
-
- return (
- svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
- className
- )}
- {...props}
- />
- )
-}
-
-function SidebarGroupAction({
- className,
- asChild = false,
- ...props
-}: React.ComponentProps<"button"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot.Root : "button"
-
- return (
- svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
- className
- )}
- {...props}
- />
- )
-}
-
-function SidebarGroupContent({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
- return (
-
- )
-}
-
-function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
- return (
-
- )
-}
-
-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 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: {
- default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
- outline: "bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
- },
- size: {
- default: "h-9 text-sm",
- sm: "h-8 text-xs",
- lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
-
-function SidebarMenuButton({
- asChild = false,
- isActive = false,
- variant = "default",
- size = "default",
- tooltip,
- className,
- ...props
-}: React.ComponentProps<"button"> & {
- asChild?: boolean
- isActive?: boolean
- tooltip?: string | React.ComponentProps
-} & VariantProps) {
- const Comp = asChild ? Slot.Root : "button"
- const { isMobile, state } = useSidebar()
-
- const button = (
-
- )
-
- if (!tooltip) {
- return button
- }
-
- if (typeof tooltip === "string") {
- tooltip = {
- children: tooltip,
- }
- }
-
- return (
-
- {button}
-
-
- )
-}
-
-function SidebarMenuAction({
- className,
- asChild = false,
- showOnHover = false,
- ...props
-}: React.ComponentProps<"button"> & {
- asChild?: boolean
- showOnHover?: boolean
-}) {
- const Comp = asChild ? Slot.Root : "button"
-
- return (
- svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
- showOnHover &&
- "peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-open:opacity-100 md:opacity-0",
- className
- )}
- {...props}
- />
- )
-}
-
-function SidebarMenuBadge({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function SidebarMenuSkeleton({
- className,
- showIcon = false,
- ...props
-}: React.ComponentProps<"div"> & {
- showIcon?: boolean
-}) {
- // Random width between 50 to 90%.
- const [width] = React.useState(() => {
- return `${Math.floor(Math.random() * 40) + 50}%`
- })
-
- return (
-
- {showIcon && (
-
- )}
-
-
- )
-}
-
-function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
- return (
-
- )
-}
-
-function SidebarMenuSubItem({
- className,
- ...props
-}: React.ComponentProps<"li">) {
- return (
-
- )
-}
-
-function SidebarMenuSubButton({
- asChild = false,
- size = "md",
- isActive = false,
- className,
- ...props
-}: React.ComponentProps<"a"> & {
- asChild?: boolean
- size?: "sm" | "md"
- isActive?: boolean
-}) {
- const Comp = asChild ? Slot.Root : "a"
-
- return (
- svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
- className
- )}
- {...props}
- />
- )
-}
-
-export {
- Sidebar,
- SidebarContent,
- SidebarFooter,
- SidebarGroup,
- SidebarGroupAction,
- SidebarGroupContent,
- SidebarGroupLabel,
- SidebarHeader,
- SidebarInput,
- SidebarInset,
- SidebarMenu,
- SidebarMenuAction,
- SidebarMenuBadge,
- SidebarMenuButton,
- SidebarMenuItem,
- SidebarMenuSkeleton,
- SidebarMenuSub,
- SidebarMenuSubButton,
- SidebarMenuSubItem,
- SidebarProvider,
- SidebarRail,
- SidebarSeparator,
- SidebarTrigger,
- useSidebar,
-}
+// 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 * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { useIsMobile } from "@/hooks/use-mobile"
+import { HugeiconsIcon } from "@hugeicons/react"
+import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
+
+const noop = () => {}
+
+const SIDEBAR_WIDTH = "16rem"
+const SIDEBAR_WIDTH_ICON = "3rem"
+const SIDEBAR_KEYBOARD_SHORTCUT = "b"
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed"
+ open: boolean
+ setOpen: (open: boolean) => void
+ openMobile: boolean
+ setOpenMobile: (open: boolean) => void
+ isMobile: boolean
+ toggleSidebar: () => void
+ hasPinMode: boolean
+ pinned: boolean
+ setPinned: (value: boolean) => void
+ togglePinned: () => void
+}
+
+const SidebarContext = React.createContext(null)
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext)
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.")
+ }
+
+ return context
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ pinned: pinnedProp,
+ setPinned: setPinnedProp,
+ togglePinned: togglePinnedProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ defaultOpen?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ pinned?: boolean
+ setPinned?: (value: boolean) => void
+ togglePinned?: () => void
+}) {
+ const isMobile = useIsMobile()
+ const [openMobile, setOpenMobile] = React.useState(false)
+
+ const prevIsMobileRef = React.useRef(isMobile)
+ React.useEffect(() => {
+ if (prevIsMobileRef.current && !isMobile) {
+ setOpenMobile(false)
+ }
+ prevIsMobileRef.current = isMobile
+ }, [isMobile])
+
+ // Whether pin mode is active (caller provides pinned + setPinned + togglePinned).
+ const hasPinMode = pinnedProp !== undefined && setPinnedProp !== undefined && togglePinnedProp !== undefined
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen)
+
+ // When pin mode is active, open is driven entirely by `pinned` (explicit
+ // user toggle). Otherwise fall back to the controlled/uncontrolled pattern.
+ const open = hasPinMode ? !!pinnedProp : (openProp ?? _open)
+
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value
+
+ if (hasPinMode) {
+ // In pin mode, setOpen controls pinned state.
+ setPinnedProp?.(openState)
+ return
+ }
+
+ if (setOpenProp) {
+ setOpenProp(openState)
+ } else {
+ _setOpen(openState)
+ }
+ },
+ [setOpenProp, open, hasPinMode, setPinnedProp]
+ )
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ if (isMobile) return setOpenMobile((open) => !open)
+ if (hasPinMode && togglePinnedProp) return togglePinnedProp()
+ return setOpen((open) => !open)
+ }, [isMobile, setOpen, setOpenMobile, hasPinMode, togglePinnedProp])
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault()
+ toggleSidebar()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [toggleSidebar])
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed"
+
+ const pinned = pinnedProp ?? false
+ const setPinned = setPinnedProp ?? noop
+ const togglePinned = togglePinnedProp ?? noop
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ hasPinMode,
+ pinned,
+ setPinned,
+ togglePinned,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ dir,
+ ...props
+}: React.ComponentProps<"div"> & {
+ side?: "left" | "right"
+ variant?: "sidebar" | "floating" | "inset"
+ collapsible?: "offcanvas" | "icon" | "none"
+}) {
+ const { isMobile, state, openMobile, setOpenMobile, hasPinMode, pinned } = useSidebar()
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ )
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ )
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+ {
+ onClick?.(event)
+ toggleSidebar()
+ }}
+ {...props}
+ >
+
+ Toggle Sidebar
+
+ )
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ )
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarContent({ className, ref, ...props }: React.ComponentProps<"div"> & { ref?: React.Ref }) {
+ return (
+ *]:shrink-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroupLabel({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "div"
+
+ return (
+ svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarGroupAction({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+ svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+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 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-[var(--icon-size)]",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline: "bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ size: {
+ default: "h-9 text-sm",
+ sm: "h-8 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function SidebarMenuButton({
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean
+ isActive?: boolean
+ tooltip?: string | React.ComponentProps
+} & VariantProps) {
+ const Comp = asChild ? Slot.Root : "button"
+ const { isMobile, state } = useSidebar()
+
+ const button = (
+
+ )
+
+ if (!tooltip) {
+ return button
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ className: "tooltip-compact",
+ }
+ }
+
+ return (
+
+ {button}
+
+
+ )
+}
+
+function SidebarMenuAction({
+ className,
+ asChild = false,
+ showOnHover = false,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean
+ showOnHover?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+ svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
+ showOnHover &&
+ "peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-open:opacity-100 md:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean
+}) {
+ // Random width between 50 to 90%.
+ const [width] = React.useState(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ })
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ )
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubButton({
+ asChild = false,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+ size?: "sm" | "md"
+ isActive?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "a"
+
+ return (
+ svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+}
diff --git a/studio/frontend/src/components/ui/tooltip.tsx b/studio/frontend/src/components/ui/tooltip.tsx
index b644c6ef32..b01dbccde1 100644
--- a/studio/frontend/src/components/ui/tooltip.tsx
+++ b/studio/frontend/src/components/ui/tooltip.tsx
@@ -10,8 +10,12 @@ import { cn } from "@/lib/utils";
type ToggleFn = () => void;
const TooltipToggleCtx = createContext(null);
+// Default to instant open (no hover delay). Most tooltips in the app —
+// chat-area icon labels, sidebar nav labels, the context/token
+// calculators — should feel snappy. Consumers that want a delay still
+// pass an explicit `delayDuration` prop.
function TooltipProvider({
- delayDuration = 400,
+ delayDuration = 0,
...props
}: React.ComponentProps) {
return (
@@ -81,25 +85,35 @@ function TooltipTrigger({
);
}
+type TooltipVariant = "default" | "rich" | "none";
+
+// `default` applies the compact black-pill styling shared with the
+// sidebar/chat icon labels. `rich` opts into the larger multi-row
+// popover surface used for timing/context breakdowns. `none` is an
+// escape hatch for tooltips that need to bring their own surface.
function TooltipContent({
+ variant = "default",
className,
sideOffset = 0,
children,
...props
-}: React.ComponentProps) {
+}: React.ComponentProps & {
+ variant?: TooltipVariant;
+}) {
return (
{children}
-
);
diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts
index dde597ca11..c854ebdbaf 100644
--- a/studio/frontend/src/features/chat/api/chat-adapter.ts
+++ b/studio/frontend/src/features/chat/api/chat-adapter.ts
@@ -17,6 +17,7 @@ import {
} from "./chat-api";
import { db } from "../db";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
+import { isMultimodalResponse } from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
import {
hasClosedThinkTag,
@@ -396,6 +397,8 @@ async function autoLoadSmallestModel(): Promise<{
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
defaultChatTemplate: loadResp.chat_template ?? null,
chatTemplateOverride: null,
+ loadedChatTemplateOverride: null,
+ loadedIsMultimodal: isMultimodalResponse(loadResp),
});
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };
@@ -455,6 +458,9 @@ async function autoLoadSmallestModel(): Promise<{
if (!store.models.some((m) => m.id === repo.repo_id)) {
store.setModels([...store.models, sfModel]);
}
+ useChatRuntimeStore.setState({
+ loadedIsMultimodal: isMultimodalResponse(sfLoadResp),
+ });
toast.success(`Loaded ${repo.repo_id}`, { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };
} catch {
@@ -522,6 +528,7 @@ async function autoLoadSmallestModel(): Promise<{
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
defaultChatTemplate: loadResp.chat_template ?? null,
chatTemplateOverride: null,
+ loadedIsMultimodal: isMultimodalResponse(loadResp),
});
toast.success("Loaded Gemma-4-E2B-it (UD-Q4_K_XL)", { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index fac9a94d30..37cbb55d48 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -19,7 +19,7 @@ import { isTauri } from "@/lib/api-base";
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 { CustomizeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
@@ -272,10 +272,10 @@ function CompareShell({
>
{children}
-
- {composer}
-
- LLMs can make mistakes. Double-check all responses.
+
+ {composer}
+
+ LLMs can make mistakes. Double-check responses.
@@ -1073,14 +1073,22 @@ export function ChatPage(): ReactElement {
setSettingsOpen(true)}
- className="flex h-[34px] w-[34px] 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="flex h-[34px] w-[34px] items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open configuration"
data-tour="chat-settings"
>
-
+
-
+
Open configuration
diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
index 08c5ef4080..f20d621d08 100644
--- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx
+++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
@@ -22,11 +22,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
- InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import {
@@ -50,23 +48,20 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
- CodeIcon,
- Delete02Icon,
- FloppyDiskIcon,
- Settings02Icon,
- Settings05Icon,
- SlidersHorizontalIcon,
- Wrench01Icon,
+ ArrowTurnBackwardIcon,
+ InformationCircleIcon,
+ LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Tooltip,
TooltipContent,
+ TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
-import { AnimatePresence, motion } from "motion/react";
+import { ChevronDown } from "lucide-react";
import { Fragment, type ReactNode } from "react";
-import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
@@ -174,7 +169,10 @@ function migrateLegacySystemPromptTemplates(presets: Preset[]): Preset[] {
localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw);
return presets;
}
- const mergedPresets = normalizeCustomPresets([...presets, ...importedPresets]);
+ const mergedPresets = normalizeCustomPresets([
+ ...presets,
+ ...importedPresets,
+ ]);
saveCustomPresets(mergedPresets);
try {
localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw);
@@ -232,6 +230,139 @@ function loadSavedActivePreset(): string {
}
}
+function InfoHint({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Editable numeric value display.
+ *
+ * Renders as a single that *looks* like text by default —
+ * transparent background, no border, no ring — and only shows a faint
+ * surface tint on hover/focus to signal editability. When unfocused,
+ * the input shows the formatted display string (`displayValue ?? value`,
+ * so labels like "Off" / "Max" still render); on focus, it switches to
+ * the raw numeric value, selects it, and accepts free text input.
+ * Commit happens on blur or Enter; Escape reverts. The clamp-to-range
+ * happens on commit so users can type intermediate values without the
+ * input fighting them mid-keystroke. Single component shared by every
+ * slider value and the Context Length input so the click-to-edit
+ * affordance is consistent across the panel.
+ */
+function snapToStep(
+ value: number,
+ step: number,
+ min?: number,
+ max?: number,
+): number {
+ const lo = min ?? Number.NEGATIVE_INFINITY;
+ const hi = max ?? Number.POSITIVE_INFINITY;
+ const clamped = Math.min(Math.max(value, lo), hi);
+ const stepStr = String(step);
+ const decimals = stepStr.includes(".") ? stepStr.split(".")[1].length : 0;
+ const base = Number.isFinite(lo) ? lo : 0;
+ const snapped = base + Math.round((clamped - base) / step) * step;
+ const reclamped = Math.min(Math.max(snapped, lo), hi);
+ return Number(reclamped.toFixed(decimals));
+}
+
+function NumericValueInput({
+ value,
+ min,
+ max,
+ step,
+ onChange,
+ displayValue,
+ className,
+ ariaLabel,
+ size: sizeAttr,
+}: {
+ value: number;
+ min?: number;
+ max?: number;
+ step: number;
+ onChange: (v: number) => void;
+ displayValue?: string;
+ className?: string;
+ ariaLabel?: string;
+ size?: number;
+}) {
+ const [focused, setFocused] = useState(false);
+ const [draft, setDraft] = useState("");
+ const cancelBlurCommitRef = useRef(false);
+
+ const commit = (raw: string) => {
+ const parsed = Number.parseFloat(raw);
+ if (!Number.isFinite(parsed)) {
+ return;
+ }
+ const final = snapToStep(parsed, step, min, max);
+ if (final !== value) {
+ onChange(final);
+ }
+ };
+
+ return (
+ {
+ cancelBlurCommitRef.current = false;
+ setDraft(String(value));
+ setFocused(true);
+ // Defer the select() so it runs after the value swap above.
+ const target = e.currentTarget;
+ requestAnimationFrame(() => target.select());
+ }}
+ onBlur={() => {
+ if (cancelBlurCommitRef.current) {
+ cancelBlurCommitRef.current = false;
+ } else {
+ commit(draft);
+ }
+ setFocused(false);
+ }}
+ onChange={(e) => setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.currentTarget.blur();
+ } else if (e.key === "Escape") {
+ cancelBlurCommitRef.current = true;
+ setDraft(String(value));
+ e.currentTarget.blur();
+ }
+ }}
+ className={cn("panel-number-input", className)}
+ />
+ );
+}
+
function ParamSlider({
label,
value,
@@ -240,6 +371,8 @@ function ParamSlider({
step,
onChange,
displayValue,
+ info,
+ valueSize,
}: {
label: string;
value: number;
@@ -248,21 +381,36 @@ function ParamSlider({
step: number;
onChange: (v: number) => void;
displayValue?: string;
+ info?: ReactNode;
+ valueSize?: number;
}) {
return (
-
-
- {label}
-
- {displayValue ?? value}
-
+
+
+
+
+ {label}
+
+ {info && {info} }
+
+
onChange(v)}
+ onValueChange={([v]) => onChange(snapToStep(v, step, min, max))}
+ className="panel-slider"
/>
);
@@ -306,15 +454,15 @@ function saveCollapsibleOpen(label: string, open: boolean) {
}
function CollapsibleSection({
- icon,
label,
children,
defaultOpen = false,
+ first = false,
}: {
- icon: Parameters[0]["icon"];
label: string;
children?: ReactNode;
defaultOpen?: boolean;
+ first?: boolean;
}) {
const [open, setOpen] = useState(() => {
const saved = loadCollapsibleState();
@@ -322,7 +470,12 @@ function CollapsibleSection({
});
return (
-
+
{
@@ -330,33 +483,19 @@ function CollapsibleSection({
setOpen(next);
saveCollapsibleOpen(label, next);
}}
- className="flex w-full items-center corner-squircle gap-2.5 rounded-md px-2 py-2 text-sm transition-colors hover:bg-accent"
- >
-
- {label}
-
-
-
-
-
- {open && (
-
- {children}
-
+ className={cn(
+ "flex w-full cursor-pointer items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors hover:text-nav-fg focus-visible:outline-none focus-visible:ring-0",
+ first ? "pt-4 pb-5" : "py-5",
)}
-
+ >
+ {label}
+
+
+
+
+ {open && {children}}
);
}
@@ -378,18 +517,26 @@ export function ChatSettingsPanel({
}: ChatSettingsPanelProps) {
const isMobile = useIsMobile();
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
+ const hasModelContent = isGguf || Boolean(params.checkpoint);
const speculativeType = useChatRuntimeStore((s) => s.speculativeType);
const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType);
const loadedSpeculativeType = useChatRuntimeStore(
(s) => s.loadedSpeculativeType,
);
- const currentModels = useChatRuntimeStore((s) => s.models);
const modelRequiresTrustRemoteCode = useChatRuntimeStore(
(s) => s.modelRequiresTrustRemoteCode,
);
const currentCheckpoint = params.checkpoint;
- const currentModelIsVision =
- currentModels.find((m) => m.id === currentCheckpoint)?.isVision ?? false;
+ const currentModelIsMultimodal = useChatRuntimeStore((s) => {
+ if (s.loadedIsMultimodal) return true;
+ const m = s.models.find((m) => m.id === currentCheckpoint);
+ return (
+ Boolean(m?.isVision) ||
+ Boolean(m?.isAudio) ||
+ Boolean(m?.hasAudioInput) ||
+ m?.audioType === "audio_vlm"
+ );
+ });
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
const ggufMaxContextLength = useChatRuntimeStore(
(s) => s.ggufMaxContextLength,
@@ -415,6 +562,16 @@ export function ChatSettingsPanel({
const ctxDirty = customContextLength !== null;
const specDirty = speculativeType !== loadedSpeculativeType;
const modelSettingsDirty = kvDirty || ctxDirty || specDirty;
+ const chatTemplateOverride = useChatRuntimeStore(
+ (s) => s.chatTemplateOverride,
+ );
+ const loadedChatTemplateOverride = useChatRuntimeStore(
+ (s) => s.loadedChatTemplateOverride,
+ );
+ const setChatTemplateOverride = useChatRuntimeStore(
+ (s) => s.setChatTemplateOverride,
+ );
+ const templateDirty = chatTemplateOverride !== loadedChatTemplateOverride;
const [customPresets, setCustomPresets] = useState(() =>
loadSavedCustomPresets(),
);
@@ -424,10 +581,6 @@ export function ChatSettingsPanel({
const [presetNameInput, setPresetNameInput] = useState(() =>
loadSavedActivePreset(),
);
- const presetControlRowRef = useRef(null);
- const [presetMenuWidthPx, setPresetMenuWidthPx] = useState<
- number | undefined
- >(undefined);
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState("");
const [activePresetBaseline, setActivePresetBaseline] = useState(params);
@@ -442,19 +595,18 @@ export function ChatSettingsPanel({
() => customPresets.find((preset) => preset.name === activePreset) ?? null,
[activePreset, customPresets],
);
+ const activeBuiltinPreset = useMemo(
+ () =>
+ BUILTIN_PRESETS.find((preset) => preset.name === activePreset) ?? null,
+ [activePreset],
+ );
const hasUnsavedPresetChanges = useMemo(
() => {
if (activePresetDefinition == null) {
return false;
}
- if (BUILTIN_PRESET_NAMES.has(activePresetDefinition.name)) {
- if (activePresetDefinition.name === "Default") {
- return activePresetSource === "modified";
- }
- return (
- activePresetSource === "modified" ||
- !isSamePresetConfig(activePresetDefinition.params, params)
- );
+ if (activePresetDefinition.name === "Default") {
+ return activePresetSource === "modified";
}
return !isSamePresetConfig(activePresetDefinition.params, params);
},
@@ -520,7 +672,10 @@ export function ChatSettingsPanel({
: trimmed;
setCustomPresets((prev) => {
const next = prev.filter((p) => p.name !== saveName);
- const merged = [...next, { name: saveName, params: toPresetParams(params) }];
+ const merged = [
+ ...next,
+ { name: saveName, params: toPresetParams(params) },
+ ];
saveCustomPresets(merged);
return merged;
});
@@ -544,7 +699,8 @@ export function ChatSettingsPanel({
return;
}
const fallbackPreset =
- BUILTIN_PRESETS.find((preset) => preset.name === "Default") ?? null;
+ BUILTIN_PRESETS.find((preset) => preset.name === "Default") ??
+ null;
setCustomPresets((prev) => {
const next = prev.filter((preset) => preset.name !== name);
saveCustomPresets(next);
@@ -587,28 +743,6 @@ export function ChatSettingsPanel({
useEffect(() => {
if (presets.some((preset) => preset.name === activePreset)) {
const expectedSource = getPresetSource(activePreset);
- if (activePresetDefinition != null) {
- if (BUILTIN_PRESET_NAMES.has(activePresetDefinition.name)) {
- if (activePresetDefinition.name === "Default") {
- if (
- activePresetSource !== "modified" &&
- activePresetSource !== expectedSource
- ) {
- setActivePresetSource(expectedSource);
- }
- return;
- }
- const matchesActivePreset = isSamePresetConfig(
- activePresetDefinition.params,
- params,
- );
- const nextSource = matchesActivePreset ? expectedSource : "modified";
- if (activePresetSource !== nextSource) {
- setActivePresetSource(nextSource);
- }
- return;
- }
- }
if (
activePresetSource !== "modified" &&
activePresetSource !== expectedSource
@@ -628,9 +762,7 @@ export function ChatSettingsPanel({
}
}, [
activePreset,
- activePresetDefinition,
activePresetSource,
- params,
presets,
setActivePresetSource,
]);
@@ -645,307 +777,302 @@ export function ChatSettingsPanel({
}
}, [open]);
- useLayoutEffect(() => {
- const el = presetControlRowRef.current;
- if (!el || !open) return;
- const measure = () => {
- setPresetMenuWidthPx(el.getBoundingClientRect().width);
- };
- measure();
- const ro = new ResizeObserver(measure);
- ro.observe(el);
- return () => ro.disconnect();
- }, [open]);
-
- const modelSection = (
-
-
- {isGguf && (
- <>
-
-
- Context Length
- {
- const raw = e.target.value;
- if (raw === "") {
- setCustomContextLength(null);
- return;
- }
- const v = Number.parseInt(raw, 10);
- if (!Number.isNaN(v) && v >= 0) {
- const maxCtx = ctxMaxValue ?? Number.POSITIVE_INFINITY;
- const clamped = Math.min(v, maxCtx);
- setCustomContextLength(
- clamped === (ggufContextLength ?? 0) ? null : clamped,
- );
- }
- }}
- />
-
- {
- setCustomContextLength(
- v === (ggufContextLength ?? 0) ? null : v,
- );
- }}
- />
- {ggufMaxContextLength != null &&
- typeof ctxDisplayValue === "number" &&
- ctxDisplayValue > ggufMaxContextLength && (
-
- Exceeds estimated VRAM capacity (
- {ggufMaxContextLength.toLocaleString()} tokens). The model
- may use system RAM.
-
- )}
-
-
-
- KV Cache Dtype
-
- Quantize KV cache to reduce VRAM.
-
-
-
-
-
-
- {!currentModelIsVision && (
-
-
-
- Speculative Decoding
-
-
- Speed up generation with no VRAM cost.
-
-
-
-
-
-
- )}
- {modelSettingsDirty && (
-
- onReloadModel?.()}
- className="rounded-md bg-primary px-2.5 py-1 text-[11px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
- >
- Apply
-
- {
- setCustomContextLength(null);
- setKvCacheDtype(loadedKvCacheDtype);
- setSpeculativeType(loadedSpeculativeType);
- }}
- className="rounded-md border px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent"
- >
- Reset
-
-
- )}
- >
- )}
- {!isGguf && params.checkpoint && (
- <>
-
-
- Enable custom code
-
- Allow models with custom code (e.g. Nemotron). Only enable if
- sure.
-
-
-
-
- {trustRemoteCodeMissing && (
-
-
- Keep custom code enabled for this model
-
-
- This model requires custom code to load. You can edit the
- toggle, but loading will stay blocked until it is turned back
- on.
-
-
- )}
- >
- )}
-
-
- );
-
const settingsContent = (
<>
-
+
{isMobile ? (
-
+
Configuration
) : (
<>
+
+ Configuration
+
onOpenChange?.(false)}
- className="flex h-[34px] w-[34px] 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="flex h-[34px] w-[34px] items-center justify-center rounded-[12px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close configuration"
>
-
+
-
+
Close configuration
-
- Configuration
-
>
)}
-
- {/* mt-4 matches the Playground sidebar gap (SidebarHeader py-3 + SidebarGroup pt-1) */}
-
-
-
-
-
- setPresetNameInput(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter" && presetSaveState.canSubmit) {
- e.preventDefault();
- savePresetWithName(presetNameInput);
+
+ {hasModelContent && (
+
+
+ {isGguf && (
+ <>
+
+
+
+ Context Length
+
+ {
+ setCustomContextLength(
+ v === (ggufContextLength ?? 0) ? null : v,
+ );
+ }}
+ ariaLabel="Context Length"
+ size={8}
+ />
+
+ {
+ const snapped = Math.round(v);
+ setCustomContextLength(
+ snapped === (ggufContextLength ?? 0) ? null : snapped,
+ );
}}
- placeholder="Preset name"
- maxLength={80}
- autoComplete="off"
- className={cn(
- "!h-8 min-h-0 min-w-0 self-stretch !pl-2.5 !pr-2 pt-1 pb-1 text-sm leading-10 md:text-sm",
- presetSaveState.isSaveReady &&
- "text-foreground placeholder:text-primary/45",
- )}
- aria-label="Inference preset name"
+ className="panel-slider"
/>
-
-
- ggufMaxContextLength && (
+
+ Exceeds estimated VRAM capacity (
+ {ggufMaxContextLength.toLocaleString()} tokens). The
+ model may use system RAM.
+
+ )}
+
+
+
+
+ KV Cache Dtype
+
+
+ Lower KV cache precision to save VRAM at the cost of some
+ quality. f16/bf16 are full precision; q8_0/q5_1/q4_1 are
+ quantized.
+
+
+
+
+
+
+ {!currentModelIsMultimodal && (
+
+
+
+ Speculative Decoding
+
+
+ N-gram speculation; faster generation with negligible
+ VRAM overhead. Text-only models.
+
+
+ {
+ setSpeculativeType(checked ? "default" : null);
+ }}
+ />
+
+ )}
+ >
+ )}
+ {!isGguf && params.checkpoint && (
+ <>
+
+
+
+ Enable custom code
+
+
+ Run custom Python from the model repo (e.g. Nemotron).
+ Only enable for trusted sources.
+
+
+
+
+ {trustRemoteCodeMissing && (
+
+
+ Keep custom code enabled for this model
+
+
+ This model requires custom code to load. You can edit the
+ toggle, but loading will stay blocked until it is turned
+ back on.
+
+
+ )}
+ >
+ )}
+
+ {(modelSettingsDirty || templateDirty) && (
+
+ onReloadModel?.()}
+ size="sm"
+ className="h-7 px-3 text-[12px] font-medium tracking-nav bg-primary/92 text-primary-foreground hover:bg-primary"
+ >
+ Apply
+
+ {
+ setCustomContextLength(null);
+ setKvCacheDtype(loadedKvCacheDtype);
+ setSpeculativeType(loadedSpeculativeType);
+ setChatTemplateOverride(loadedChatTemplateOverride);
+ }}
+ className="h-7 px-3 text-[12px] font-medium tracking-nav text-muted-foreground"
+ >
+ Reset
+
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+ setPresetNameInput(e.target.value)}
+ onPointerDown={(e) => e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && presetSaveState.canSubmit) {
+ e.preventDefault();
+ savePresetWithName(presetNameInput);
+ }
+ e.stopPropagation();
+ }}
+ placeholder="Preset name"
+ maxLength={80}
+ autoComplete="off"
+ className={cn(
+ "!h-9 min-h-0 min-w-0 self-stretch !pl-3.5 !pr-2 py-0 text-[13px] font-medium leading-9 text-nav-fg md:text-[13px]",
+ presetSaveState.isSaveReady &&
+ "placeholder:text-primary/50",
+ )}
+ aria-label="Inference preset name"
+ />
+
+
-
-
- {presets.map((p, index) => (
-
- applyPreset(p.name)}>
- {p.name}
-
- {index === BUILTIN_PRESETS.length - 1 &&
- presets.length > BUILTIN_PRESETS.length && (
-
- )}
-
- ))}
-
-
-
-
+
+
+
+
+
+
+ {presets.map((p, index) => (
+
+ applyPreset(p.name)}
+ className="flex min-h-9 items-center px-3 py-0 text-[13px] font-medium leading-[1.4] tracking-nav"
+ >
+ {p.name}
+
+ {index === BUILTIN_PRESETS.length - 1 &&
+ presets.length > BUILTIN_PRESETS.length && (
+
+ )}
+
+ ))}
+
+
+
savePresetWithName(presetNameInput)}
@@ -953,16 +1080,13 @@ export function ChatSettingsPanel({
variant={presetSaveState.isSaveReady ? "default" : "outline"}
size="sm"
className={cn(
- "h-8 w-full text-xs",
+ "h-9 w-full rounded-[10px] text-[13px] font-medium tracking-nav",
presetSaveState.isSaveReady &&
- "bg-primary/92 text-primary-foreground hover:bg-primary",
+ "bg-primary text-primary-foreground hover:bg-primary/90",
)}
title={presetSaveState.title}
aria-label={presetSaveState.title}
>
-
-
-
{presetSaveState.buttonLabel}
-
-
-
Delete
-
+
-
-
-
-
- Edit
-
-
-
+
+
+
+ {params.systemPrompt ||
+ "Example: You are a helpful assistant..."}
+
+
+
-
-
+
+
{!isGguf && (
)}
- {modelSection}
-
-
-
+
+
-
-
@@ -1176,7 +1289,7 @@ export function ChatSettingsPanel({
if (isMobile) {
return (
-
+
Configuration
Chat inference settings
@@ -1189,9 +1302,9 @@ export function ChatSettingsPanel({
return (
);
}
@@ -1216,6 +1329,7 @@ function MaxToolCallsSlider() {
displayValue={
sliderValue >= 41 ? "Max" : sliderValue === 0 ? "Off" : undefined
}
+ info="Cap on tool/function calls the model may invoke within a single response. 0 disables tool use; Max removes the cap."
/>
);
}
@@ -1243,6 +1357,8 @@ function ToolCallTimeoutSlider() {
step={1}
onChange={(v) => setTimeout_(v >= 31 ? 9999 : v)}
displayValue={displayValue}
+ valueSize={10}
+ info="Per-call wall-clock limit. Long-running tool executions are terminated when this elapses; the model continues with what completed."
/>
);
}
@@ -1255,13 +1371,17 @@ function AutoHealToolCallsToggle() {
return (
-
- Auto Heal Tool Calls 🦥
-
- Fix malformed tool calls from the model automatically.
-
+
+
+ Auto-Healing Tool Calls
+
+
+ Unsloth auto-fixes broken tool calls so inference output is never
+ broken.
+
@@ -1269,53 +1389,116 @@ function AutoHealToolCallsToggle() {
);
}
-function ChatTemplateSection({
- onReloadModel,
-}: {
- onReloadModel?: () => void;
-}) {
+function ChatTemplateFields() {
const defaultTemplate = useChatRuntimeStore((s) => s.defaultChatTemplate);
const override = useChatRuntimeStore((s) => s.chatTemplateOverride);
const setOverride = useChatRuntimeStore((s) => s.setChatTemplateOverride);
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [draft, setDraft] = useState("");
if (!defaultTemplate) return null;
const displayValue = override ?? defaultTemplate;
const isModified = override !== null;
+ const draftDirty = draft !== displayValue;
+
+ const openEditor = () => {
+ setDraft(displayValue);
+ setEditorOpen(true);
+ };
+ const saveEditor = () => {
+ setOverride(
+ draft.trim().length === 0 || draft === defaultTemplate ? null : draft,
+ );
+ setEditorOpen(false);
+ };
return (
-
-
-