mirror of
https://github.com/unslothai/unsloth
synced 2026-04-21 13:37:39 +00:00
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:
parent
0a5c61ffcc
commit
c20959dbf4
32 changed files with 808 additions and 435 deletions
BIN
studio/frontend/public/circle-logo-small.png
Normal file
BIN
studio/frontend/public/circle-logo-small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
studio/frontend/public/fonts/FiraCode-VariableFont_wght.ttf
Normal file
BIN
studio/frontend/public/fonts/FiraCode-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
studio/frontend/public/fonts/Hellix-Medium.woff
Normal file
BIN
studio/frontend/public/fonts/Hellix-Medium.woff
Normal file
Binary file not shown.
BIN
studio/frontend/public/fonts/Hellix-Regular.woff
Normal file
BIN
studio/frontend/public/fonts/Hellix-Regular.woff
Normal file
Binary file not shown.
BIN
studio/frontend/public/sidebar-logo-black.png
Normal file
BIN
studio/frontend/public/sidebar-logo-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9 KiB |
BIN
studio/frontend/public/sidebar-logo-white.png
Normal file
BIN
studio/frontend/public/sidebar-logo-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
BIN
studio/frontend/public/unsloth-beta-black.png
Normal file
BIN
studio/frontend/public/unsloth-beta-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
studio/frontend/public/unsloth-beta-white.png
Normal file
BIN
studio/frontend/public/unsloth-beta-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
|
|
@ -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,8 +605,6 @@ export function AppSidebar() {
|
|||
<span>What's New</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href="https://github.com/unslothai/unsloth/issues"
|
||||
|
|
@ -601,6 +618,12 @@ export function AppSidebar() {
|
|||
<span>Feedback</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
|
||||
<HugeiconsIcon icon={PowerIcon} className="size-4" />
|
||||
<span>Shutdown</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
|
@ -608,6 +631,11 @@ export function AppSidebar() {
|
|||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<ChatSearchDialog />
|
||||
<ShutdownDialog
|
||||
open={shutdownOpen}
|
||||
onOpenChange={setShutdownOpen}
|
||||
onAfterShutdown={removeTrainingUnloadGuard}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
66
studio/frontend/src/components/assistant-ui/code-plugin.ts
Normal file
66
studio/frontend/src/components/assistant-ui/code-plugin.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
30
studio/frontend/src/components/assistant-ui/code-themes.ts
Normal file
30
studio/frontend/src/components/assistant-ui/code-themes.ts
Normal 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",
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
{/* 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(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
<CompareShell
|
||||
handlesRef={handlesRef}
|
||||
composer={<SharedComposer handlesRef={handlesRef} />}
|
||||
>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="px-3 py-1.5">
|
||||
<>
|
||||
<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"
|
||||
}
|
||||
/>
|
||||
<ComparePane
|
||||
modelType="lora"
|
||||
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">
|
||||
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,15 +421,25 @@ 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"
|
||||
<CompareShell
|
||||
handlesRef={handlesRef}
|
||||
composer={
|
||||
<SharedComposer
|
||||
handlesRef={handlesRef}
|
||||
model1={model1}
|
||||
model2={model2}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 px-3">
|
||||
<ModelSelector
|
||||
<>
|
||||
<ComparePane
|
||||
modelType="model1"
|
||||
pairId={pairId}
|
||||
initialThreadId={model1ThreadId}
|
||||
handleName="model1"
|
||||
header={
|
||||
<GeneralCompareHeader
|
||||
side="left"
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
value={model1.id}
|
||||
|
|
@ -319,28 +451,18 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
})
|
||||
}
|
||||
onFoldersChange={onFoldersChange}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="max-w-[80%]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatRuntimeProvider
|
||||
modelType="model1"
|
||||
}
|
||||
/>
|
||||
<ComparePane
|
||||
modelType="model2"
|
||||
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
|
||||
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}
|
||||
|
|
@ -352,35 +474,11 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
})
|
||||
}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -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,7 +882,7 @@ export function ChatPage(): ReactElement {
|
|||
{modelsError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{view.mode === "single" && ggufContextLength && contextUsage ? (
|
||||
<ContextUsageBar
|
||||
used={contextUsage.totalTokens}
|
||||
|
|
@ -792,17 +890,28 @@ export function ChatPage(): ReactElement {
|
|||
cached={contextUsage.cachedTokens}
|
||||
promptTokens={contextUsage.promptTokens}
|
||||
completionTokens={contextUsage.completionTokens}
|
||||
className="h-[34px]"
|
||||
/>
|
||||
) : null}
|
||||
{!settingsOpen && (
|
||||
<Tooltip>
|
||||
<TooltipPrimitive.Trigger asChild>
|
||||
<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"
|
||||
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={Settings04Icon} className="size-5" />
|
||||
<HugeiconsIcon icon={Settings05Icon} className="size-5" />
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Open configuration
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.mode === "single" ? (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
63
studio/frontend/src/hooks/use-collapse-scroll-lock.ts
Normal file
63
studio/frontend/src/hooks/use-collapse-scroll-lock.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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---*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue