Studio: Improve chat composition, fix scroll behaviour, and refine sidebar UX (#5089)

* Chatbox, scroll, and menu fixes

- Fixed chatbox auto-expand height for multi-line text on the compare page
- Fixed chatbox UI to be consistent across compare and new chat
- Fixed scrolling being enabled on pages with no content, which also triggered the scroll-to-bottom button
- Fixed scroll-to-bottom button to only appear after scrolling up a reasonable amount instead of instantly
- Added shutdown studio button to the menu for easier access
- Fixed pop-up menu width to match the user button width

(cherry picked from commit cd4e390dfa84fe311fae79a781b96cc0ef5970a9)

* fix: correct compare scroll viewport and clean up chat composer UI polish

* Dark theme refactor and sidebar/chat UI refinements

- Complete refactoring of dark theme
- Replaced square rounded-corner user profile image with a circular bordered one
- Replaced user profile icon with 'U' initial and renamed label from 'Studio' to 'User'
- Chat bubbles now have a pointy top-right edge
- Sidebar menu tab line color selection is now consistent across all menus
- Tab-selection color animation now also applies to recent chats
- Removed 'Compare' menu autoselect when a compare chat conversation is selected
- Fixed UI consistency in Compare to match New Chat
- Removed sidebar animation and tab line, replaced with rounded selection for consistency
- Further adjustments to sidebar UI
- Further adjustments to compare chat UI

* Fixed sidebar collapse/expand for recent chats and recent runs not being clickable

* Chatbox, scroll, and menu fixes

- Fixed chatbox auto-expand height for multi-line text on the compare page
- Fixed chatbox UI to be consistent across compare and new chat
- Fixed scrolling being enabled on pages with no content, which also triggered the scroll-to-bottom button
- Fixed scroll-to-bottom button to only appear after scrolling up a reasonable amount instead of instantly
- Added shutdown studio button to the menu for easier access
- Fixed pop-up menu width to match the user button width

* Sidebar, fonts, and chat UI refinements

- Replaced logo PNG with real font text for 'unsloth' and 'BETA' label
- Added Hellix font and applied it across menus and UI elements
- Lighter scrollbar in the sidebar compared to other areas of the app
- Adjusted chat font and chat bubble styling
- Adjusted app menu design to stay consistent with the sidebar
- Adjusted text style for 'New Chat' and repositioned content/chatbox
- Adjusted model selector and top area UI
- Fixed footer text from 'LLM's' to 'LLMs'
- Fixed active selection border color incorrectly appearing on page refresh and during general navigation
- Logo now defaults to 'New Chat' when clicked

* Sidebar, model selector, and mobile UI fixes

- Further adjustments to sidebar UI and logo
- Changed right bar icon
- Model selector adjustments
- Collapsed sidebar now matches the content area background
- Adjusted Hellix font spacing across pages
- Fixed sidebar icon overlap on mobile screens

* Adjust sidebar icons

* Adjust sidebar icons

* Fixed compare chat UI and scrolling issues

* Fixed inference settings icon behavior and context info positioning

- Fixed top right inference settings icon to move into sidepanel during expand/collapse, matching left sidebar behavior
- Adjusted context information element positioning

* Fix: textarea overflow in system prompt editor

* Code block redesign, font, and chat bubble adjustments

- Redesigned code block colors and theme
- Changed code block font to Fira Code
- Fixed scrollbar disappearing when expanding/collapsing tool calls in chats
- Adjusted chat bubble background color

* Fix chat bubble background color in dark theme

* fix: restore textarea auto-sizing and scope prompt editor sizing

* fix: add explicit textarea field sizing for prompt editor overflow

* fix: generate chat nonce on click instead of render

* fix: respect training lock on logo navigation

* Refactor compare page dual chat scrolling behavior

* Revert "Refactor compare page dual chat scrolling behavior"

This reverts commit d056ec09f2.

---------

Co-authored-by: sneakr <hauzin@hotmail.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
This commit is contained in:
Lee Jackson 2026-04-20 23:20:45 +01:00 committed by GitHub
parent 0a5c61ffcc
commit c20959dbf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 808 additions and 435 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

View file

@ -36,11 +36,14 @@ import {
ColumnInsertIcon,
CursorInfo02Icon,
Delete02Icon,
Download03Icon,
GemIcon,
MessageSearch01Icon,
Search01Icon,
NewReleasesIcon,
PackageIcon,
PowerIcon,
PencilEdit02Icon,
LayoutAlignLeftIcon,
Settings02Icon,
ZapIcon,
} from "@hugeicons/core-free-icons";
@ -50,9 +53,8 @@ import {
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, ChevronsUpDown, Moon, PanelLeft, Sun } from "lucide-react";
import { ChevronDown, ChevronsUpDown, Moon, Sun } from "lucide-react";
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useTrainingRuntimeStore } from "@/features/training";
import { useSettingsDialogStore } from "@/features/settings";
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
@ -67,7 +69,9 @@ import { useChatSearchStore } from "@/features/chat/stores/chat-search-store";
import { ChatSearchDialog } from "@/features/chat/components/chat-search-dialog";
import { useTrainingHistorySidebarItems, deleteTrainingRun } from "@/features/training";
import type { TrainingRunSummary } from "@/features/training";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ShutdownDialog } from "@/components/shutdown-dialog";
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
function getTourId(pathname: string): string | null {
if (pathname.startsWith("/studio")) return "studio";
@ -76,8 +80,6 @@ function getTourId(pathname: string): string | null {
return null;
}
const NAV_SPRING = { type: "spring", stiffness: 500, damping: 35, mass: 0.5 } as const;
function runStatusDotClass(status: TrainingRunSummary["status"]): string {
switch (status) {
case "running":
@ -107,6 +109,13 @@ function formatRelativeShort(iso: string): string {
return `${d}d`;
}
function createNavigationNonce(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function NavItem({
icon,
label,
@ -130,13 +139,6 @@ function NavItem({
return (
<SidebarMenuItem>
<div className="relative">
{isNav && active && (
<motion.div
layoutId="sidebar-active-indicator"
className="absolute left-0 top-0 bottom-0 w-[3px] rounded-full bg-primary"
transition={NAV_SPRING}
/>
)}
<SidebarMenuButton
tooltip={label}
disabled={disabled}
@ -145,12 +147,12 @@ function NavItem({
data-tour={dataTour}
className={
isNav
? "rounded-none pr-0 pl-4 text-[#475569] dark:text-[#94a3b8] data-active:text-foreground!"
: "rounded-none pr-0 pl-4 text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
? "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"
}
>
<HugeiconsIcon icon={icon} strokeWidth={2} className="size-[18px]" />
<span className="text-[13px] font-medium">{label}</span>
<HugeiconsIcon icon={icon} strokeWidth={1.5} className="size-[18px]!" />
<span className="text-sm">{label}</span>
</SidebarMenuButton>
</div>
{children}
@ -176,14 +178,16 @@ export function AppSidebar() {
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
const chatOnly = usePlatformStore((s) => s.isChatOnly());
const [shutdownOpen, setShutdownOpen] = useState(false);
// Chat collapsible state — open by default, syncs with route
// Chat collapsible state — open by default, auto-expand on route entry
const isChatRoute = pathname.startsWith("/chat");
const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/");
const [chatOpen, setChatOpen] = useState(true);
const [runsOpen, setRunsOpen] = useState(true);
const effectiveChatOpen = isChatRoute || chatOpen;
const effectiveRunsOpen = isStudioRoute || runsOpen;
useEffect(() => { if (isChatRoute) setChatOpen(true); }, [isChatRoute]);
useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]);
const isRecipesRoute = pathname.startsWith("/data-recipes");
const { displayTitle, avatarDataUrl } = useEffectiveProfile();
@ -219,26 +223,43 @@ export function AppSidebar() {
return (
<>
<Sidebar collapsible="icon" variant="sidebar">
<SidebarHeader className="group-data-[collapsible=icon]:px-0">
<Sidebar
collapsible="icon"
variant="sidebar"
className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background"
>
<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 px-1 py-1 group-data-[collapsible=icon]:hidden">
<div className="flex items-center justify-between gap-2 group-data-[collapsible=icon]:hidden">
<Link
to={chatOnly ? "/chat" : "/studio"}
onClick={closeMobileIfOpen}
className="flex items-center select-none"
to="/chat"
onClick={(event) => {
event.preventDefault();
if (chatDisabled) return;
setActiveThreadId(null);
closeMobileIfOpen();
void navigate({
to: "/chat",
search: { new: createNavigationNonce() },
});
}}
className="flex items-center gap-[6px] select-none"
aria-label="Unsloth home"
>
<img
src="/blacklogo-c.png"
src="/circle-logo-small.png"
alt="Unsloth"
className="h-7 w-auto dark:hidden"
/>
<img
src="/whitelogo-c.png"
alt="Unsloth"
className="hidden h-7 w-auto dark:block"
className="h-[34px] w-[34px] rounded-full object-cover"
/>
<span className="font-heading text-[21px] font-semibold tracking-[-0.01em] dark:tracking-[0.02em] leading-none text-black dark:text-white">
unsloth
</span>
<span
style={{ fontFamily: '"Inter Variable", ui-sans-serif, system-ui, sans-serif' }}
className="ml-0.5 inline-flex items-center justify-center rounded-full border border-[#e0ded6] px-[5px] py-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-[#62605a] antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:border-[#3a3c3f] dark:text-[#9d9fa5] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]"
>
BETA
</span>
</Link>
{!isMobile && (
<Tooltip>
@ -246,10 +267,10 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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"
aria-label="Close sidebar"
>
<PanelLeft strokeWidth={1.5} className="size-4" />
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6}>
@ -259,18 +280,18 @@ export function AppSidebar() {
)}
</div>
{/* Collapsed: sticker with hover-swap to open toggle */}
{/* Collapsed: panel icon doubles as expand trigger */}
{!isMobile && (
<div className="hidden group-data-[collapsible=icon]:flex items-center justify-center h-9 w-full">
<div className="hidden group-data-[collapsible=icon]:flex h-[34px] items-center justify-center w-full">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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"
aria-label="Open sidebar"
>
<PanelLeft strokeWidth={1.5} className="size-4" />
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="right" sideOffset={8}>
@ -281,7 +302,7 @@ export function AppSidebar() {
)}
</SidebarHeader>
<SidebarGroup className="group-data-[collapsible=icon]:p-0 p-0 pt-1 shrink-0">
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[8px] pb-[12px] shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
@ -292,20 +313,20 @@ export function AppSidebar() {
onClick={() => {
if (chatDisabled) return;
setActiveThreadId(null);
navigate({ to: "/chat", search: { new: crypto.randomUUID() } });
navigate({ to: "/chat", search: { new: createNavigationNonce() } });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ColumnInsertIcon}
label="Compare"
active={!!search.compare}
active={!!search.compare && !chatItems.some((i) => i.id === search.compare)}
disabled={chatDisabled}
dataTour="chat-compare"
onClick={() => {
if (chatDisabled) return;
setActiveThreadId(null);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
navigate({ to: "/chat", search: { compare: createNavigationNonce() } });
closeMobileIfOpen();
}}
/>
@ -322,16 +343,15 @@ export function AppSidebar() {
/>
</SidebarMenu>
</SidebarGroupContent>
<div className="my-2" />
</SidebarGroup>
<SidebarContent className="gap-0 overflow-y-auto overscroll-contain min-h-0">
{/* Navigate (no header) */}
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:p-0 p-0">
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[8px] pb-[12px]">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={ZapIcon}
icon={GemIcon}
label="Train"
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
@ -353,7 +373,7 @@ export function AppSidebar() {
/>
<NavItem
icon={PackageIcon}
icon={Download03Icon}
label="Export"
active={pathname === "/export" || pathname.startsWith("/export/")}
disabled={chatOnly}
@ -365,16 +385,15 @@ export function AppSidebar() {
/>
</SidebarMenu>
</SidebarGroupContent>
<div className="my-2" />
</SidebarGroup>
{/* Recent Chats */}
{chatItems.length > 0 && (
<Collapsible open={effectiveChatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden p-0">
<SidebarGroupLabel asChild>
{/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */}
{!isStudioRoute && chatItems.length > 0 && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recent Chats
Recents
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
@ -385,7 +404,7 @@ export function AppSidebar() {
<SidebarMenuItem key={item.id} className="group/recent-item relative">
<SidebarMenuButton
isActive={activeThreadId === item.id}
className="rounded-none pl-4 pr-7 text-[13px] font-medium text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
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!"
onClick={() => {
navigate({
to: "/chat",
@ -406,7 +425,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-md 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-[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"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
@ -421,11 +440,11 @@ export function AppSidebar() {
{/* Recent Runs */}
{isStudioRoute && runItems.length > 0 && !chatOnly && (
<Collapsible open={effectiveRunsOpen} onOpenChange={setRunsOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden p-0">
<SidebarGroupLabel asChild>
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recent Runs
Recents
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
@ -442,7 +461,7 @@ export function AppSidebar() {
>
<SidebarMenuButton
isActive={isActiveRun}
className="h-auto flex-col items-start gap-0.5 py-2 rounded-none pl-4 pr-7 text-[13px] font-medium text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
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!"
onClick={() => {
setSelectedHistoryRunId(run.id);
closeMobileIfOpen();
@ -456,7 +475,7 @@ export function AppSidebar() {
)}
aria-hidden
/>
<span className="truncate text-sm font-medium">
<span className="truncate text-sm">
{run.model_name}
</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
@ -482,7 +501,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-md 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-[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"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
@ -497,7 +516,7 @@ export function AppSidebar() {
)}
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-t-0">
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-t-0 group-data-[collapsible=icon]:px-0">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
@ -505,7 +524,7 @@ export function AppSidebar() {
<SidebarMenuButton
size="lg"
aria-label={`${displayTitle} account menu`}
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:overflow-visible group-data-[collapsible=icon]:hover:bg-transparent group-data-[collapsible=icon]:data-[state=open]:bg-transparent"
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!"
>
<div className="shrink-0">
<UserAvatar
@ -516,8 +535,8 @@ export function AppSidebar() {
/>
</div>
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">{displayTitle}</span>
<span className="truncate text-[11px] text-muted-foreground">Train</span>
<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>
</div>
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
@ -525,7 +544,7 @@ export function AppSidebar() {
<DropdownMenuContent
side="top"
align="start"
className="w-56"
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!"
>
<DropdownMenuGroup>
<DropdownMenuItem
@ -586,20 +605,24 @@ export function AppSidebar() {
<span>What's New</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href="https://github.com/unslothai/unsloth/issues"
target="_blank"
rel="noopener noreferrer"
>
<HugeiconsIcon
icon={MessageSearch01Icon}
className="size-4"
/>
<span>Feedback</span>
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a
href="https://github.com/unslothai/unsloth/issues"
target="_blank"
rel="noopener noreferrer"
>
<HugeiconsIcon
icon={MessageSearch01Icon}
className="size-4"
/>
<span>Feedback</span>
</a>
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
<HugeiconsIcon icon={PowerIcon} className="size-4" />
<span>Shutdown</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -608,6 +631,11 @@ export function AppSidebar() {
</SidebarFooter>
</Sidebar>
<ChatSearchDialog />
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
</>
);
}

View file

@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
createCodePlugin as createShikiCodePlugin,
type CodeHighlighterPlugin,
type CodePluginOptions,
type HighlightOptions,
type HighlightResult,
} from "@streamdown/code";
import type { BundledLanguage } from "shiki";
// Fence tags LLMs/users commonly write that shiki doesn't expose as aliases.
// Keys are lower-cased input; values are canonical shiki language ids.
const LANGUAGE_ALIAS_OVERRIDES: Record<string, BundledLanguage> = {
objectivec: "objective-c",
"obj-c": "objective-c",
objectivecpp: "objective-cpp",
"objective-cplusplus": "objective-cpp",
objcpp: "objective-cpp",
"c++": "cpp",
cplusplus: "cpp",
"c#": "csharp",
cs: "csharp",
"f#": "fsharp",
"c-sharp": "csharp",
"f-sharp": "fsharp",
golang: "go",
rs: "rust",
rb: "ruby",
py: "python",
sh: "shellscript",
bash: "shellscript",
zsh: "shellscript",
shell: "shellscript",
yml: "yaml",
ts: "typescript",
js: "javascript",
kt: "kotlin",
rsx: "rust",
"vue-html": "vue",
};
const normalizeLanguage = (language: string): BundledLanguage => {
const key = language.trim().toLowerCase();
const override = LANGUAGE_ALIAS_OVERRIDES[key];
return (override ?? (key as BundledLanguage));
};
export function createCodePlugin(
options: CodePluginOptions = {},
): CodeHighlighterPlugin {
const inner = createShikiCodePlugin(options);
return {
...inner,
supportsLanguage: (language) => inner.supportsLanguage(normalizeLanguage(language)),
highlight: (
opts: HighlightOptions,
callback?: (result: HighlightResult) => void,
) =>
inner.highlight(
{ ...opts, language: normalizeLanguage(opts.language) },
callback,
),
};
}

View file

@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import oneDarkPro from "@shikijs/themes/one-dark-pro";
import oneLight from "@shikijs/themes/one-light";
import type { ThemeRegistrationAny } from "shiki";
// Canonical Atom One Dark / One Light themes, shipped by `@shikijs/themes`.
// We only override the background so the code block blends into the app's
// `--code-block` surface instead of painting its own. Every token color and
// scope mapping is left intact — that's what gives consistent multi-language
// highlighting (including Objective-C, Go, Rust, etc.) out of the box.
const withTransparentBg = (theme: ThemeRegistrationAny): ThemeRegistrationAny => ({
...theme,
bg: "transparent",
colors: {
...theme.colors,
"editor.background": "transparent",
},
});
export const unslothLightTheme: ThemeRegistrationAny = {
...withTransparentBg(oneLight),
name: "unsloth-light",
};
export const unslothDarkTheme: ThemeRegistrationAny = {
...withTransparentBg(oneDarkPro),
name: "unsloth-dark",
};

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { FC } from "react";
export const CodeToggleIcon: FC<{ className?: string }> = ({ className }) => {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
};

View file

@ -8,7 +8,7 @@ import { preprocessLaTeX } from "@/lib/latex";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { code } from "@streamdown/code";
import { createCodePlugin } from "./code-plugin";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
@ -16,8 +16,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
const math = createMathPlugin({ singleDollarTextMath: true });
const code = createCodePlugin({
themes: [unslothLightTheme, unslothDarkTheme],
});
const { withSmoothContextProvider } = INTERNAL;
const STREAMDOWN_COMPONENTS = {
@ -425,7 +429,7 @@ const MarkdownTextImpl = () => {
panZoom: true,
},
}}
shikiTheme={["github-light", "github-dark"]}
shikiTheme={[unslothLightTheme, unslothDarkTheme]}
BlockComponent={StreamdownBlock}
>
{processedText}

View file

@ -68,9 +68,9 @@ function ModelSelectorTrigger({
className={cn(
"flex items-center gap-2 transition-colors",
variant === "outline" &&
"rounded-full border border-border/60 hover:bg-accent",
variant === "ghost" && "rounded-md hover:bg-accent",
variant === "muted" && "rounded-md bg-muted hover:bg-muted/80",
"rounded-[8px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
variant === "ghost" && "rounded-[8px] hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
variant === "muted" && "rounded-[8px] bg-muted hover:bg-muted/80",
size === "sm" && "h-8 px-3 text-xs",
size === "default" && "h-9 px-3.5 text-sm",
size === "lg" && "h-10 px-4 text-sm",
@ -80,15 +80,16 @@ function ModelSelectorTrigger({
{isLoaded && (
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
)}
<span className={isLoaded ? "text-foreground" : "text-muted-foreground"}>
{currentModel?.name ?? "Select model..."}
<span className="font-heading font-medium text-[16px] text-black dark:text-white">
{currentModel?.name ?? "Select model"}
</span>
{currentModel?.description && (
<span className="text-muted-foreground text-xs">{currentModel.description}</span>
)}
<HugeiconsIcon
icon={ArrowDown01Icon}
className="size-3 shrink-0 text-muted-foreground"
strokeWidth={1.75}
className="size-3.5 shrink-0 text-muted-foreground"
/>
</button>
</PopoverTrigger>

View file

@ -152,8 +152,8 @@ function ModelRow({
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
selected && "bg-accent/60",
"flex w-full items-center gap-2 rounded-[6px] px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
selected && "bg-[#ececec] dark:bg-[#2e3035]",
)}
>
<span
@ -376,7 +376,7 @@ function GgufVariantExpander({
handleVariantClick(v.quant, v.downloaded, v.size_bytes)
}
className={cn(
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-md px-2.5 py-1 text-left text-sm transition-colors hover:bg-accent",
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-[6px] px-2.5 py-1 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
)}
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">

View file

@ -11,6 +11,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
import {
type ReasoningGroupComponent,
@ -67,49 +68,8 @@ function ReasoningRoot({
...props
}: ReasoningRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const lockCleanupRef = useRef<(() => void) | null>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
useEffect(() => {
return () => {
lockCleanupRef.current?.();
};
}, []);
const lockScroll = useCallback(() => {
lockCleanupRef.current?.();
const animatedElement = collapsibleRef.current;
if (!animatedElement) return;
let scrollContainer: HTMLElement | null = animatedElement;
while (scrollContainer) {
const { overflowY } = getComputedStyle(scrollContainer);
if (overflowY === "scroll" || overflowY === "auto") {
break;
}
scrollContainer = scrollContainer.parentElement;
}
if (!scrollContainer) return;
const scrollPosition = scrollContainer.scrollTop;
const resetPosition = () => {
scrollContainer.scrollTop = scrollPosition;
};
scrollContainer.addEventListener("scroll", resetPosition);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
scrollContainer.removeEventListener("scroll", resetPosition);
lockCleanupRef.current = null;
};
timeoutId = setTimeout(cleanup, ANIMATION_DURATION);
lockCleanupRef.current = cleanup;
}, []);
const lockScroll = useCollapseScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;

View file

@ -16,6 +16,7 @@ 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 { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import { Button } from "@/components/ui/button";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
@ -34,6 +35,7 @@ import {
useAui,
useAuiEvent,
useAuiState,
useThreadViewport,
} from "@assistant-ui/react";
import { motion } from "motion/react";
import {
@ -69,12 +71,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
}) => {
return (
<ThreadPrimitive.Root
className={cn(
"aui-root aui-thread-root @container flex flex-col",
hideComposer
? "h-full"
: "relative min-h-0 min-w-0 flex-1 basis-0 overflow-hidden",
)}
className="aui-root aui-thread-root @container relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"
style={{
["--thread-max-width" as string]: "44rem",
["--thread-content-max-width" as string]:
@ -83,10 +80,8 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
>
<ThreadPrimitive.Viewport
className={cn(
"aui-thread-viewport relative flex min-w-0 flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-5",
hideComposer
? "pt-4"
: "h-0 min-h-0 basis-0 pt-[56px]",
"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 && (
@ -103,31 +98,29 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
}}
/>
{/* Small overlap and extra slack so the last lines can scroll under the composer cleanly */}
{!hideComposer && <div className="h-40 shrink-0" aria-hidden />}
<ThreadPrimitive.ViewportFooter
className={cn(
"aui-thread-viewport-footer sticky z-20 mt-auto flex w-full flex-col overflow-visible bg-transparent",
hideComposer
? "bottom-0 gap-2"
: "bottom-[140px] shrink-0 gap-3",
// Compare: pointer-events pass-through so messages behind footer stay clickable
hideComposer
? "pointer-events-none pb-3"
: "pb-2",
)}
>
{/* 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 }) => !thread.isEmpty}>
<div
className={cn("shrink-0", hideComposer ? "h-16" : "h-40")}
aria-hidden
/>
</AuiIf>
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<ThreadPrimitive.ViewportFooter
className={cn(
"flex justify-center",
hideComposer && "pointer-events-auto",
"aui-thread-viewport-footer pointer-events-none sticky z-20 flex w-full justify-center bg-transparent",
hideComposer ? "bottom-3" : "bottom-[140px]",
)}
>
<ThreadScrollToBottom />
</div>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.ViewportFooter>
</AuiIf>
</ThreadPrimitive.Viewport>
{!hideComposer && (
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
@ -140,7 +133,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
<ComposerAnimated />
</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
LLM's can make mistakes. Double-check all responses.
LLMs can make mistakes. Double-check all responses.
</p>
</div>
</div>
@ -151,12 +144,24 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
};
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);
return (
<ThreadPrimitive.ScrollToBottom asChild={true}>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom absolute -top-6 z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent"
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>
@ -227,7 +232,7 @@ const SuggestionItem: FC = () => {
const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
return (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center pb-[48px]">
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-6 px-4">
<div className="flex flex-col items-center gap-2 text-center">
<img
@ -235,10 +240,10 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
alt="Sloth mascot"
className="size-20"
/>
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-bold text-2xl tracking-[-0.02em] duration-200">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-heading font-semibold text-2xl tracking-[-0.02em] duration-200">
Chat with your model
</h1>
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in text-muted-foreground text-sm delay-75 duration-200">
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 -mt-1 animate-in font-heading font-normal text-muted-foreground text-sm delay-75 duration-200">
Run GGUFs, safetensors, vision and audio models
</p>
</div>
@ -303,13 +308,13 @@ const PendingAudioChip: FC = () => {
const Composer: FC = () => {
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
<PendingAudioChip />
<ToolStatusDisplay />
<ComposerPrimitive.Input
placeholder="Send a message..."
className="aui-composer-input mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
className="aui-composer-input mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
minRows={1}
maxRows={6}
autoFocus={true}
@ -483,24 +488,6 @@ const CodeToolsToggle: FC = () => {
);
};
const CodeToggleIcon: FC<{ className?: string }> = ({ className }) => {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
};
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -638,7 +625,7 @@ const GeneratingIndicator: FC = () => {
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) animate-in py-0.5 text-[15.5px] duration-150"
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) animate-in py-0.5 text-[15.5px] font-[450] duration-150"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-foreground leading-relaxed">
@ -791,14 +778,14 @@ const UserMessageAudio: FC = () => {
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-0.5 text-[15.5px] duration-150"
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-0.5 text-[15.5px] font-[450] duration-150"
data-role="user"
>
<UserMessageAttachments />
<UserMessageAudio />
<div className="aui-user-message-content-wrapper flex max-w-[80%] min-w-0 flex-col items-end">
<div className="aui-user-message-content wrap-break-word w-fit rounded-2xl bg-muted px-4 py-2.5 text-foreground">
<div className="aui-user-message-content wrap-break-word w-fit rounded-[16px] rounded-tr-[4px] bg-[#f5f5f5] px-4 py-2.5 text-foreground dark:bg-card">
<MessagePrimitive.Parts />
</div>
<div className="mt-1 flex min-h-6">
@ -844,7 +831,7 @@ const EditComposer: FC = () => {
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-content-max-width) flex-col py-3">
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm font-[450] outline-none"
autoFocus={true}
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">

View file

@ -8,11 +8,11 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
import {
type ToolCallMessagePartComponent,
type ToolCallMessagePartStatus,
useScrollLock,
} from "@assistant-ui/react";
import {
AlertCircleIcon,
@ -52,7 +52,7 @@ function ToolFallbackRoot({
}: ToolFallbackRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const lockScroll = useCollapseScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;

View file

@ -12,12 +12,12 @@ import { ChevronDownIcon, LoaderIcon } from "lucide-react";
import { Wrench01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cva, type VariantProps } from "class-variance-authority";
import { useScrollLock } from "@assistant-ui/react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
const ANIMATION_DURATION = 200;
@ -54,7 +54,7 @@ function ToolGroupRoot({
}: ToolGroupRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const lockScroll = useCollapseScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;

View file

@ -7,13 +7,13 @@ export function Navbar() {
const { isMobile } = useSidebar();
if (!isMobile) {
return (
<header className="absolute top-0 inset-x-0 z-40 h-11 pointer-events-none" />
<header className="absolute top-0 inset-x-0 z-40 h-[48px] pointer-events-none" />
);
}
return (
<header className="absolute top-0 inset-x-0 z-40 h-11 pointer-events-none">
<div className="flex h-full items-center pl-2">
<SidebarTrigger className="pointer-events-auto" />
<header className="absolute top-0 inset-x-0 z-40 h-[48px] pointer-events-none">
<div className="flex h-full items-start pt-[11px] pl-2">
<SidebarTrigger className="pointer-events-auto !size-[34px]" />
</div>
</header>
);

View file

@ -26,7 +26,7 @@ import {
} from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"
import { HugeiconsIcon } from "@hugeicons/react"
import { SidebarLeftIcon } from "@hugeicons/core-free-icons"
import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
const noop = () => {}
@ -337,7 +337,7 @@ function SidebarTrigger({
}}
{...props}
>
<HugeiconsIcon icon={SidebarLeftIcon} strokeWidth={2} />
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
@ -471,7 +471,7 @@ function SidebarGroupLabel({
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-[#94a3b8] dark:text-[#64748b] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
"text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
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! focus-visible:ring-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-5",
"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]",
{
variants: {
variant: {

View file

@ -79,7 +79,7 @@ function Slider({
>
<SliderPrimitive.Track
data-slot="slider-track"
className="bg-muted rounded-4xl data-horizontal:h-3 data-horizontal:w-full data-vertical:h-full data-vertical:w-3 bg-muted relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full cursor-pointer"
className="bg-black/10 dark:bg-black/12 rounded-4xl data-horizontal:h-2 data-horizontal:w-full data-vertical:h-full data-vertical:w-2 relative grow overflow-hidden cursor-pointer"
>
<SliderPrimitive.Range
data-slot="slider-range"
@ -103,7 +103,7 @@ function Slider({
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 relative z-10 size-4 rounded-4xl border bg-white shadow-sm block shrink-0 select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 transition-transform duration-100 ease-out hover:scale-110 hover:ring-4 active:scale-95 focus-visible:ring-4 focus-visible:outline-hidden"
className="ring-ring/50 relative z-10 size-4 rounded-4xl bg-white shadow-sm block shrink-0 select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 transition-transform duration-100 ease-out hover:scale-110 hover:ring-4 active:scale-95 focus-visible:ring-4 focus-visible:outline-hidden"
/>
))}
</SliderPrimitive.Root>

View file

@ -5,12 +5,21 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
type TextareaProps = React.ComponentProps<"textarea"> & {
fieldSizing?: "content" | "fixed";
};
function Textarea({
className,
fieldSizing = "content",
...props
}: TextareaProps) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input bg-input/30 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 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
"border-input bg-input/30 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 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex min-h-16 min-w-0 max-w-full w-full whitespace-pre-wrap break-words [overflow-wrap:anywhere] outline-none disabled:cursor-not-allowed disabled:opacity-50",
fieldSizing === "content" ? "field-sizing-content" : "[field-sizing:fixed]",
className,
)}
{...props}

View file

@ -11,9 +11,14 @@ import { cn } from "@/lib/utils";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { useSidebar } from "@/components/ui/sidebar";
import {
Settings04Icon,
Settings05Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
type ReactElement,
@ -39,6 +44,7 @@ import {
import { ChatRuntimeProvider } from "./runtime-provider";
import {
type CompareHandle,
type CompareHandles,
CompareHandlesProvider,
RegisterCompareHandle,
SharedComposer,
@ -166,6 +172,96 @@ const CompareContent = memo(function CompareContent({
);
});
/**
* A single column in the compare layout. Hosts one ChatRuntimeProvider
* and one Thread rendered with hideComposer the composer is shared
* across both panes and rendered outside the pane flex.
*
* Each pane is a flex item with `flex-1 basis-0 min-h-0 min-w-0` so on
* mobile (flex-col) they share height equally, and on desktop (flex-row)
* they share width equally. The `min-*` constraints are required for
* the inner viewport to scroll internally instead of spilling into the
* page.
*/
function ComparePane({
modelType,
pairId,
initialThreadId,
handleName,
header,
borderClassName,
}: {
modelType: "base" | "lora" | "model1" | "model2";
pairId: string;
initialThreadId: string | undefined;
handleName: string;
header: ReactElement;
borderClassName?: string;
}): ReactElement {
return (
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden",
borderClassName,
)}
>
{header}
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden [&_.aui-thread-viewport]:px-6 lg:[&_.aui-thread-viewport]:px-10">
<ChatRuntimeProvider
modelType={modelType}
pairId={pairId}
initialThreadId={initialThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name={handleName} />
<Thread hideComposer={true} hideWelcome={true} />
</ChatRuntimeProvider>
</div>
</div>
);
}
/**
* Shared shell for both compare variants. A vertical flex column with
* the two panes as siblings and the shared composer docked at the
* bottom. On mobile the panes stack (flex-col); on desktop they sit
* side by side (md:flex-row).
*
* Flex is used rather than CSS grid for the pane container so that
* viewport sizing stays stable across viewport-size transitions. Grid
* rows with 1fr were triggering resize thrash in assistant-ui's
* autoscroll hook on breakpoint crossings, leaving it stuck in a
* scroll-to-bottom loop.
*/
function CompareShell({
handlesRef,
children,
composer,
}: {
handlesRef: CompareHandles;
children: ReactElement;
composer: ReactElement;
}): ReactElement {
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col">
<div
data-tour="chat-compare-view"
className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col md:flex-row"
>
{children}
</div>
<div className="shrink-0 bg-background px-5 pb-2 pt-1">
<div className="mx-auto w-full max-w-[44rem]">{composer}</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
LLMs can make mistakes. Double-check all responses.
</p>
</div>
</div>
</CompareHandlesProvider>
);
}
/** Fast path: same model, adapter on/off, simultaneous generation. */
const LoraCompareContent = memo(function LoraCompareContent({
pairId,
@ -191,61 +287,87 @@ const LoraCompareContent = memo(function LoraCompareContent({
}, [pairId]);
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 flex-1 flex-col">
<div
data-tour="chat-compare-view"
className="grid min-h-0 flex-1 grid-cols-1 px-0 md:grid-cols-2"
>
<div className="flex min-h-0 flex-col">
<div className="px-3 py-1.5">
<CompareShell
handlesRef={handlesRef}
composer={<SharedComposer handlesRef={handlesRef} />}
>
<>
<ComparePane
modelType="base"
pairId={pairId}
initialThreadId={baseThreadId}
handleName="base"
header={
<div className="shrink-0 px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Base Model
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<ChatRuntimeProvider
modelType="base"
pairId={pairId}
initialThreadId={baseThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="base" />
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideComposer={true} hideWelcome={true} />
</div>
</ChatRuntimeProvider>
</div>
</div>
<div className="flex min-h-0 flex-col border-t border-border/60 md:border-t-0 md:border-l">
<div className="px-3 py-1.5 text-start md:text-end">
}
/>
<ComparePane
modelType="lora"
pairId={pairId}
initialThreadId={loraThreadId}
handleName="lora"
borderClassName="border-t border-border/60 md:border-t-0 md:border-l"
header={
<div className="shrink-0 px-3 py-1.5 text-start md:text-end">
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
Fine-tuned
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<ChatRuntimeProvider
modelType="lora"
pairId={pairId}
initialThreadId={loraThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="lora" />
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideComposer={true} hideWelcome={true} />
</div>
</ChatRuntimeProvider>
</div>
</div>
</div>
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 bg-background px-4 pt-2 pb-4">
<SharedComposer handlesRef={handlesRef} />
</div>
</div>
</CompareHandlesProvider>
}
/>
</>
</CompareShell>
);
});
/**
* Per-pane header rendered inside GeneralCompareContent. Contains the
* model selector aligned with the global topbar height. The left pane
* reserves room for the mobile sidebar trigger; the right pane reserves
* room for the global settings button.
*/
function GeneralCompareHeader({
models,
loraModels,
value,
onValueChange,
onFoldersChange,
side,
}: {
models: ModelOption[];
loraModels: LoraModelOption[];
value: string;
onValueChange: (
id: string,
meta: { isLora: boolean; ggufVariant?: string },
) => void;
onFoldersChange?: () => void;
side: "left" | "right";
}): ReactElement {
return (
<div
className={cn(
"flex h-[48px] shrink-0 items-center gap-2 bg-background",
side === "left" ? "pl-12 pr-3 md:pl-2" : "pl-3 pr-12",
)}
>
<ModelSelector
models={models}
loraModels={loraModels}
value={value}
onValueChange={onValueChange}
onFoldersChange={onFoldersChange}
variant="ghost"
className="max-w-[80%] !h-[34px]"
/>
</div>
);
}
/** General path: any two models, sequential load → generate. */
const GeneralCompareContent = memo(function GeneralCompareContent({
pairId,
@ -299,88 +421,64 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
}, [pairId]);
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 flex-1 flex-col">
<div
data-tour="chat-compare-view"
className="grid min-h-0 flex-1 grid-cols-1 px-0 md:grid-cols-2"
>
<div className="flex min-h-0 flex-col">
<div className="flex h-11 shrink-0 items-center gap-2 px-3">
<ModelSelector
models={models}
loraModels={loraModels}
value={model1.id}
onValueChange={(id, meta) =>
setModel1({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
})
}
onFoldersChange={onFoldersChange}
variant="ghost"
size="sm"
className="max-w-[80%]"
/>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<ChatRuntimeProvider
modelType="model1"
pairId={pairId}
initialThreadId={model1ThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model1" />
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideComposer={true} hideWelcome={true} />
</div>
</ChatRuntimeProvider>
</div>
</div>
<div className="flex min-h-0 flex-col border-t border-sidebar-border md:border-t-0 md:border-l">
<div className="flex h-11 shrink-0 items-center gap-2 px-3">
<ModelSelector
models={models}
loraModels={loraModels}
value={model2.id}
onValueChange={(id, meta) =>
setModel2({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
})
}
onFoldersChange={onFoldersChange}
variant="ghost"
size="sm"
className="max-w-[80%]"
/>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<ChatRuntimeProvider
modelType="model2"
pairId={pairId}
initialThreadId={model2ThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model2" />
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideComposer={true} hideWelcome={true} />
</div>
</ChatRuntimeProvider>
</div>
</div>
</div>
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 bg-background px-4 pt-2 pb-4">
<SharedComposer
handlesRef={handlesRef}
model1={model1}
model2={model2}
/>
</div>
</div>
</CompareHandlesProvider>
<CompareShell
handlesRef={handlesRef}
composer={
<SharedComposer
handlesRef={handlesRef}
model1={model1}
model2={model2}
/>
}
>
<>
<ComparePane
modelType="model1"
pairId={pairId}
initialThreadId={model1ThreadId}
handleName="model1"
header={
<GeneralCompareHeader
side="left"
models={models}
loraModels={loraModels}
value={model1.id}
onValueChange={(id, meta) =>
setModel1({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
})
}
onFoldersChange={onFoldersChange}
/>
}
/>
<ComparePane
modelType="model2"
pairId={pairId}
initialThreadId={model2ThreadId}
handleName="model2"
borderClassName="border-t border-sidebar-border md:border-t-0 md:border-l"
header={
<GeneralCompareHeader
side="right"
models={models}
loraModels={loraModels}
value={model2.id}
onValueChange={(id, meta) =>
setModel2({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
})
}
onFoldersChange={onFoldersChange}
/>
}
/>
</>
</CompareShell>
);
});
@ -734,9 +832,9 @@ export function ChatPage(): ReactElement {
<div className="relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<div
className={cn(
"absolute top-0 left-0 right-2 z-30 flex h-11 shrink-0 items-center pr-2 bg-background",
"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-2 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">
@ -754,7 +852,7 @@ export function ChatPage(): ReactElement {
onOpenChange={handleModelSelectorOpenChange}
triggerDataTour="chat-model-selector"
contentDataTour="chat-model-selector-popover"
className="max-w-[62vw] sm:max-w-none"
className="max-w-[62vw] sm:max-w-none !h-[34px]"
/>
)}
{loadingModel && loadToastDismissed ? (
@ -784,25 +882,36 @@ export function ChatPage(): ReactElement {
{modelsError}
</div>
)}
<div className="flex-1" />
{view.mode === "single" && ggufContextLength && contextUsage ? (
<ContextUsageBar
used={contextUsage.totalTokens}
total={ggufContextLength}
cached={contextUsage.cachedTokens}
promptTokens={contextUsage.promptTokens}
completionTokens={contextUsage.completionTokens}
/>
) : null}
<button
type="button"
onClick={() => setSettingsOpen(!settingsOpen)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Inference settings"
data-tour="chat-settings"
>
<HugeiconsIcon icon={Settings04Icon} className="size-5" />
</button>
<div className="ml-auto flex items-center gap-2">
{view.mode === "single" && ggufContextLength && contextUsage ? (
<ContextUsageBar
used={contextUsage.totalTokens}
total={ggufContextLength}
cached={contextUsage.cachedTokens}
promptTokens={contextUsage.promptTokens}
completionTokens={contextUsage.completionTokens}
className="h-[34px]"
/>
) : null}
{!settingsOpen && (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={() => 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"
aria-label="Open configuration"
data-tour="chat-settings"
>
<HugeiconsIcon icon={Settings05Icon} className="size-5" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6}>
Open configuration
</TooltipContent>
</Tooltip>
)}
</div>
</div>
{view.mode === "single" ? (

View file

@ -52,12 +52,17 @@ import {
CodeIcon,
Delete02Icon,
FloppyDiskIcon,
PencilEdit01Icon,
Settings02Icon,
Settings05Icon,
SlidersHorizontalIcon,
Wrench01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { AnimatePresence, motion } from "motion/react";
import type { ReactNode } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
@ -738,15 +743,34 @@ export function ChatSettingsPanel({
const settingsContent = (
<>
<div className="aui-thread-viewport relative h-full overflow-y-auto bg-muted/70">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-muted/70 px-4 py-3 backdrop-blur">
<HugeiconsIcon
icon={PencilEdit01Icon}
className="size-4 text-muted-foreground/70"
/>
<span className="flex-1 text-base font-semibold tracking-tight">
Configuration
</span>
<div className="aui-thread-viewport relative h-full overflow-y-auto">
<div className="sticky top-0 z-10 flex h-[48px] items-start gap-2 pl-2 pr-2 pt-[11px] backdrop-blur">
{isMobile ? (
<span className="flex h-[34px] flex-1 items-center pl-1 text-base font-semibold tracking-tight">
Configuration
</span>
) : (
<>
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={() => 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"
aria-label="Close configuration"
>
<HugeiconsIcon icon={Settings05Icon} className="size-5" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6}>
Close configuration
</TooltipContent>
</Tooltip>
<span className="flex h-[34px] flex-1 items-center text-base font-semibold tracking-tight">
Configuration
</span>
</>
)}
</div>
<div className="px-1.5">
@ -889,7 +913,7 @@ export function ChatSettingsPanel({
value={params.systemPrompt}
onChange={(e) => set("systemPrompt")(e.target.value)}
placeholder="You are a helpful assistant..."
className="min-h-20 max-h-48 overflow-y-auto text-xs corner-squircle"
className="min-h-20 max-h-48 overflow-y-auto text-xs corner-squircle focus-visible:ring-[1px]"
rows={3}
/>
</div>
@ -1202,6 +1226,7 @@ export function ChatSettingsPanel({
value={systemPromptDraft}
onChange={(event) => setSystemPromptDraft(event.target.value)}
placeholder="You are a helpful assistant..."
fieldSizing="fixed"
className="min-h-[24rem] max-h-[50vh] overflow-y-auto text-sm leading-6 corner-squircle"
rows={14}
/>

View file

@ -2,11 +2,12 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import { Button } from "@/components/ui/button";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
import { useAui } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, TerminalIcon, XIcon } from "lucide-react";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { loadModel, validateModel } from "./api/chat-api";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
@ -267,6 +268,21 @@ export function SharedComposer({
return () => clearInterval(id);
}, [handlesRef]);
// Auto-expand textarea up to 6 rows, then scroll (matches regular chat composer).
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.style.height = "auto";
const styles = window.getComputedStyle(ta);
const lineHeight = parseFloat(styles.lineHeight) || 20;
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const maxHeight = lineHeight * 6 + paddingY + borderY;
const next = Math.min(ta.scrollHeight, maxHeight);
ta.style.height = `${next}px`;
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
}, [text]);
const addFiles = useCallback((files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
@ -455,7 +471,7 @@ export function SharedComposer({
return (
<div
className={`chat-composer-surface relative flex w-full flex-col rounded-3xl bg-background px-1 pt-2 transition-shadow outline-none ${dragging ? "border-ring bg-accent/50" : ""}`}
className={`chat-composer-surface relative flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 transition-shadow outline-none ${dragging ? "border-ring bg-accent/50" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@ -498,7 +514,7 @@ export function SharedComposer({
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Send to both models..."
className="mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground"
className="mb-1 min-h-12 w-full resize-none overflow-y-hidden bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
rows={1}
/>
<div className="relative mx-2 mb-2 flex items-center justify-between">
@ -515,13 +531,13 @@ export function SharedComposer({
}}
/>
<TooltipIconButton
tooltip="Add attachment"
tooltip="Add Attachment"
side="bottom"
variant="ghost"
size="icon"
className="size-8 rounded-full text-muted-foreground hover:bg-muted-foreground/15"
className="size-8.5 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
onClick={() => fileInputRef.current?.click()}
aria-label="Add attachment"
aria-label="Add Attachment"
>
<PlusIcon className="size-5 stroke-[1.5px]" />
</TooltipIconButton>
@ -542,11 +558,11 @@ export function SharedComposer({
side="bottom"
variant="ghost"
size="icon"
className="size-8 rounded-full text-muted-foreground hover:bg-muted-foreground/15"
className="size-8.5 rounded-full p-1 text-muted-foreground hover:bg-muted-foreground/15"
onClick={() => audioInputRef.current?.click()}
aria-label="Upload audio"
>
<HeadphonesIcon className="size-4 stroke-[1.5px]" />
<HeadphonesIcon className="size-4.5 stroke-[1.5px]" />
</TooltipIconButton>
</>
)}
@ -569,7 +585,7 @@ export function SharedComposer({
}
}}
className={cn(
"flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-medium transition-colors",
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: (reasoningEnabled || reasoningAlwaysOn)
@ -579,9 +595,9 @@ export function SharedComposer({
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
>
{(reasoningEnabled || reasoningAlwaysOn) && !reasoningDisabled ? (
<LightbulbIcon className="size-3" />
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3" />
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
@ -616,7 +632,7 @@ export function SharedComposer({
)}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
>
<TerminalIcon className="size-3.5" />
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
</button>
</div>
@ -629,7 +645,7 @@ export function SharedComposer({
side="bottom"
variant="ghost"
size="icon"
className="size-8 rounded-full text-muted-foreground hover:bg-muted-foreground/15"
className="size-8 rounded-full text-muted-foreground"
onClick={startDictation}
aria-label="Dictate"
>

View file

@ -132,7 +132,7 @@ function AuxNodeBase({
const hasInvalidRefs =
findInvalidJinjaReferences(value, availableRefs).length > 0;
return (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<BaseNode className="corner-squircle w-full min-w-0 rounded-4xl border-border/60 bg-card shadow-sm">
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">{data.title}</BaseNodeHeaderTitle>
</BaseNodeHeader>
@ -195,7 +195,7 @@ function AuxNodeBase({
};
return (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<BaseNode className="corner-squircle w-full min-w-0 rounded-4xl border-border/60 bg-card shadow-sm">
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">
{score.name.trim() || `Scorer ${data.scoreIndex + 1}`}

View file

@ -399,7 +399,7 @@ function RecipeGraphNodeBase({
return (
<BaseNode
className="corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm"
className="corner-squircle relative w-full min-w-0 overflow-visible rounded-4xl border-border/60 shadow-sm"
style={noteStyle}
>
<NodeResizer
@ -465,7 +465,7 @@ function RecipeGraphNodeBase({
return (
<BaseNode
className={cn(
"corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm",
"corner-squircle relative w-full min-w-0 overflow-visible rounded-4xl border-border/60 shadow-sm",
runtimeNodeTone,
hasConnectionIssue &&
runtimeState === "idle" &&

View file

@ -83,7 +83,7 @@ export function SettingsDialog() {
Manage your Unsloth Studio preferences.
</DialogDescription>
<div className="flex h-full min-h-0">
<aside className="flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2">
<aside className="font-heading flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2">
<nav className="flex flex-col gap-0.5">
{TABS.map((tab) => {
const active = activeTab === tab.id;
@ -93,17 +93,17 @@ export function SettingsDialog() {
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-9 items-center gap-2 rounded-md px-2.5 text-sm font-medium transition-colors",
"relative flex h-[30px] items-center gap-2.5 rounded-[8px] px-2.5 text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
? "text-black dark:text-white"
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white",
)}
>
{active && (
<motion.span
layoutId="settings-active-pill"
className="absolute inset-0 rounded-md bg-accent"
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2e3035]"
transition={
reduced
? { duration: 0 }
@ -118,7 +118,8 @@ export function SettingsDialog() {
)}
<HugeiconsIcon
icon={tab.icon}
className="relative z-10 size-4"
strokeWidth={1.5}
className="relative z-10 size-[18px]"
/>
<span className="relative z-10">{tab.label}</span>
</button>
@ -131,7 +132,7 @@ export function SettingsDialog() {
<button
type="button"
onClick={closeDialog}
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="absolute top-3 right-3 z-10 flex size-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"
aria-label="Close settings"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />

View file

@ -11,3 +11,4 @@ export { useHfDatasetSearch } from "./use-hf-dataset-search";
export { useHfDatasetSplits } from "./use-hf-dataset-splits";
export { useHfTokenValidation } from "./use-hf-token-validation";
export { useInfiniteScroll } from "./use-infinite-scroll";
export { useCollapseScrollLock } from "./use-collapse-scroll-lock";

View file

@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { type RefObject, useCallback, useEffect, useRef } from "react";
/**
* Locks the nearest scrollable ancestor's scrollTop for the duration of a
* collapsible animation so the page doesn't jump when content height changes.
*
* Unlike @assistant-ui/react's `useScrollLock`, this hook does NOT toggle
* `scrollbar-width: none` on the container. Hiding the scrollbar mid-animation
* caused a visible disappear/reappear flicker on tool-call collapsibles.
*/
export function useCollapseScrollLock(
animatedElementRef: RefObject<HTMLElement | null>,
animationDurationMs: number,
): () => void {
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
return () => {
cleanupRef.current?.();
};
}, []);
return useCallback(() => {
cleanupRef.current?.();
const animatedElement = animatedElementRef.current;
if (!animatedElement) return;
let scrollContainer: HTMLElement | null = animatedElement;
while (scrollContainer) {
const { overflowY } = getComputedStyle(scrollContainer);
if (overflowY === "scroll" || overflowY === "auto") {
break;
}
scrollContainer = scrollContainer.parentElement;
}
if (!scrollContainer) return;
const container = scrollContainer;
const scrollPosition = container.scrollTop;
const resetPosition = () => {
container.scrollTop = scrollPosition;
};
container.addEventListener("scroll", resetPosition);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
container.removeEventListener("scroll", resetPosition);
if (cleanupRef.current === cleanup) {
cleanupRef.current = null;
}
};
timeoutId = setTimeout(cleanup, animationDurationMs);
cleanupRef.current = cleanup;
}, [animatedElementRef, animationDurationMs]);
}

View file

@ -14,6 +14,22 @@
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: "Hellix";
src: url("/fonts/Hellix-Regular.woff") format("woff");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Hellix";
src: url("/fonts/Hellix-Medium.woff") format("woff");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Hellix";
src: url("/fonts/Hellix-SemiBold.woff2") format("woff2"),
@ -23,6 +39,14 @@
font-display: swap;
}
@font-face {
font-family: "Fira Code";
src: url("/fonts/FiraCode-VariableFont_wght.ttf") format("truetype-variations");
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
:root {
/* Animation timing */
--duration-micro: 100ms;
@ -100,39 +124,44 @@
}
.dark {
--background: oklch(0.24 0 0);
--foreground: oklch(0.98 0 0);
--card: oklch(0.28 0 0);
--card-foreground: oklch(0.98 0 0);
--popover: oklch(0.28 0 0);
--popover-foreground: oklch(0.98 0 0);
/* Exact palette from apps/studio/index_chat.html mockup. Using hex so the
rendered surface matches the mockup pixel-for-pixel OKLCH conversion
drifted ~3% darker and shifted the neutral hue. */
--background: #1a1b1e;
--foreground: #d4d4d4;
--card: #222427;
--card-foreground: #d4d4d4;
--popover: #222427;
--popover-foreground: #d4d4d4;
--primary: oklch(0.6929 0.1396 166.5513);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.33 0 0);
--secondary-foreground: oklch(0.98 0 0);
--muted: oklch(0.33 0 0);
--muted-foreground: oklch(0.70 0 0);
--accent: oklch(0.33 0 0);
--accent-foreground: oklch(0.98 0 0);
--secondary: #2e3035;
--secondary-foreground: #d4d4d4;
--muted: #2e3035;
--muted-foreground: #999999;
--accent: #2e3035;
--accent-foreground: #d4d4d4;
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.38 0 0);
--input: oklch(0.38 0 0);
--border: #2e3035;
/* --input one step lighter than --muted so form borders stay visible on
muted surfaces (right config panel) and keep subtle contrast on card. */
--input: #3a3d42;
--ring: oklch(0.6929 0.1396 166.5513);
--chart-1: oklch(0.7511 0.1407 166.2284);
--chart-2: oklch(0.75 0.14 136.5572);
--chart-3: oklch(0.7554 0.1285 197.339);
--chart-4: oklch(0.7503 0.1199 346.7805);
--chart-5: oklch(0.799 0.1196 84.6633);
--sidebar: oklch(0.24 0 0);
--sidebar-foreground: oklch(0.98 0 0);
--sidebar: #222427;
--sidebar-foreground: #d4d4d4;
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.33 0 0);
--sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(0.38 0 0);
--sidebar-accent: #2e3035;
--sidebar-accent-foreground: #d4d4d4;
--sidebar-border: #2e3035;
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
--destructive-foreground: oklch(1 0 0);
--radius: 1.1rem;
--radius: 0.625rem;
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
@ -305,9 +334,16 @@
@layer utilities {
/* Heading font utility */
/* 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. */
.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) */
@ -332,11 +368,8 @@
}
.dark .chat-composer-surface {
border-color: oklch(0.38 0 0 / 1);
box-shadow:
0 1px 2px oklch(0 0 0 / 0.2),
0 8px 20px oklch(0 0 0 / 0.25),
0 22px 48px oklch(0 0 0 / 0.22);
border-color: #2e3035;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
@ -375,8 +408,8 @@
}
[data-streamdown="code-block"] {
gap: 0;
padding: 0.5rem;
gap: 0.25rem;
padding: 0.75rem 1rem;
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
max-width: 100%;
min-width: 0;
@ -384,7 +417,14 @@
}
[data-streamdown="code-block-header"] {
padding-left: 0.75rem;
padding-left: 0;
}
.aui-thread-root [data-streamdown="code-block"] code > span::before {
content: none !important;
display: none !important;
margin: 0 !important;
width: 0 !important;
}
/* Chat thread: code slightly smaller by default; step up when the thread column is wide. */
@ -416,11 +456,11 @@
/* Keep monospace for code fences and inline code (not KaTeX). */
.aui-thread-root [data-streamdown="code-block"] pre,
.aui-thread-root [data-streamdown="code-block"] code {
font-family: var(--font-mono), ui-monospace, monospace;
font-family: "Fira Code", ui-monospace, monospace;
}
.aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {
font-family: var(--font-mono), ui-monospace, monospace;
font-family: "Fira Code", ui-monospace, monospace;
}
/* Align fenced code blocks with the main chat column even when nested in lists. */
@ -478,6 +518,9 @@
full-height rail flush to the right edge, without a separate decorative strip. */
.aui-thread-viewport {
scrollbar-color: oklch(0.5 0 0 / 0.54) var(--sidebar);
/* Reserve scrollbar space always so absolute-positioned overlays (topbar)
can stop flush with the gutter edge without covering the scrollbar. */
scrollbar-gutter: stable;
}
.dark .aui-thread-viewport {
@ -489,11 +532,19 @@
}
[data-sidebar="content"] {
scrollbar-color: oklch(0.5 0 0 / 0.7) transparent;
scrollbar-color: oklch(0.5 0 0 / 0.22) transparent;
}
.dark [data-sidebar="content"] {
scrollbar-color: oklch(0.72 0 0 / 0.65) transparent;
scrollbar-color: oklch(0.72 0 0 / 0.25) transparent;
}
[data-sidebar="content"]::-webkit-scrollbar-thumb {
background: oklch(0.5 0 0 / 0.22);
}
.dark [data-sidebar="content"]::-webkit-scrollbar-thumb {
background: oklch(0.72 0 0 / 0.25);
}
/*---break---*/