Compare commits

...
Sign in to create a new pull request.

20 commits

Author SHA1 Message Date
shimmyshimmer
fbc5b81b54 style(chat): fade composer dock edge and tune plus morph timing
The composer dock backdrop was a solid block with a hard top edge, so
chat text scrolling underneath got cut off abruptly. Replace it with a
gradient that stays solid behind the composer and fades to transparent
over the top 28px.

Also set the plus to x morph to 250ms; 200ms felt too abrupt and 300ms
too slow.
2026-06-03 20:38:47 -07:00
wasimysaid
b24a450f26 style(chat): restore composer active control states 2026-05-31 04:07:43 +02:00
wasimysaid
5d2b189068 fix(chat): gate compare menu by model support 2026-05-30 23:20:34 +02:00
wasimysaid
72eff32065 style(chat): polish composer and sidebar interactions 2026-05-30 23:00:41 +02:00
wasimysaid
7bc44536ea fix(chat): preserve compare state and align composer controls 2026-05-30 22:45:14 +02:00
wasimysaid
def605d5b6 fix(chat): harden composer tool and compare flows 2026-05-30 22:02:08 +02:00
wasimysaid
19436f589a style(chat): refine composer pills 2026-05-30 21:19:16 +02:00
wasimysaid
14384cd4e5 style(studio): polish chat navigation and composer 2026-05-30 21:02:49 +02:00
wasimysaid
65a9cf102a feat(sidebar): polish chat navigation 2026-05-30 20:22:14 +02:00
wasimysaid
b76d90d1b3 feat(chat): align compare composer 2026-05-30 20:22:14 +02:00
wasimysaid
f7b92e5f11 feat(chat): update single composer tools 2026-05-30 20:22:14 +02:00
wasimysaid
1b58287c07 style(studio): tune chat ui styles 2026-05-30 20:22:14 +02:00
shimmyshimmer
7d53d49f22 Studio: refine chat composer and add compare-mode parity
- Composer expands to two rows only once the input wraps to a second line,
  not on the first keystroke. Re-measure the autosize textarea on the width
  swap so expanding no longer leaves a stray blank row.
- Light-mode composer shadow now matches Gemini's soft elevation.
- Plus menu: replace Canvas with a More submenu (Canvas, Compare chat, RAG)
  and add Code above MCP. Active Web search/Code items use medium weight.
- Compare mode: the plus side menu, Search/Code toggles, and a Compare exit
  pill now match single chat, with the thinking control on the right.
- Projects menu entries link to their tracking PR (#5725).
- Add cursor-pointer to the composer plus button.
2026-05-30 07:28:58 -07:00
Michael Han
a879ca1d82
Merge branch 'main' into studio-composer 2026-05-30 05:11:22 -07:00
shimmyshimmer
fc23036470 Merge branch 'main' into studio-composer 2026-05-30 01:07:40 -07:00
shimmyshimmer
cbb92749a8 Studio: link MCP Servers heading to its PR and fix composer pill cursors
Make the "MCP Servers" heading in the chat Configuration sheet link to the
MCP PR, keeping the chevron as the toggle. The label and chevron are rendered
as siblings so we don't nest an <a> inside a <button>.

Also add cursor-pointer to the composer pills and the thinking pill so hovering
a clickable pill shows the hand cursor instead of the default arrow.
2026-05-30 01:06:08 -07:00
Unsloth
e56d69f528 studio: reflect pre-selected Search/Code tools when no model is loaded
The Search and Code pills only lit up when the tool was usable right now
(a model loaded and capable), so a tool turned on from the + menu showed
as off in the pill while the menu showed it on. toolsEnabled is persisted
and takes effect once a capable model loads, so the pill should reflect it.
The pills now disable only when a loaded model lacks the capability, and
otherwise reflect the selected state. Applied to the main and compare
composers.
2026-05-29 03:19:06 -07:00
Michael Han
518c7e9916
Merge branch 'main' into studio-composer 2026-05-29 02:56:53 -07:00
Unsloth
83b2dfd08e studio: sync compare-composer reasoning state and harden compare id
- Compare composer: keep "Preserve thinking" consistent with reasoning,
  matching the main composer. Enabling it now turns reasoning on, and
  disabling reasoning (the None option or the Thinking toggle) turns it
  off, so the invalid "preserve on while thinking off" state can't occur.
- Guard crypto.randomUUID in the Compare action. It is undefined in
  non-secure contexts (HTTP over a LAN IP) and would throw; fall back to
  a timestamped random id, matching createNavigationNonce.
2026-05-29 02:38:11 -07:00
Unsloth
cd67cb2223 studio: redesign chat composer
Reworks the new-chat composer and the compare composer into a single
rounded pill surface with a softer, lighter look.

- New welcome screen with a time-of-day sloth mascot and a lighter
  heading.
- One rounded composer surface with a soft drop shadow. The input grows
  inline as you type and collapses back to a single row when cleared.
- Tools and attachments live in a single plus menu; the thinking control
  is a compact pill with a reasoning-effort submenu.
- Inlined glyphs for the thinking, send, and dictate controls, kept in
  sync across the main and compare composers.
- Toast notifications match the composer surface: no border line, the
  same drop shadow, and the same dark surface color, with a ring-less
  close button.
- Dark mode: the side-menu shadow blends into the background, hovered
  menu rows read clearly, and their roundness matches light mode.
- Composer styles use dedicated unsloth- prefixed classes so compare
  mode keeps its own stacked layout.
2026-05-29 01:40:22 -07:00
13 changed files with 3800 additions and 2153 deletions

View file

@ -42,7 +42,6 @@ import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
ChefHatIcon, ChefHatIcon,
ColumnInsertIcon,
CursorInfo02Icon, CursorInfo02Icon,
Delete02Icon, Delete02Icon,
DownloadSquare01Icon, DownloadSquare01Icon,
@ -58,13 +57,16 @@ import {
TestTube01Icon, TestTube01Icon,
ZapIcon, ZapIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "lucide-react"; import {
ChevronDown,
ChevronsUpDown,
MoreHorizontalIcon,
Moon,
Sun,
} from "lucide-react";
import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import { import {
ChatSearchDialog, ChatSearchDialog,
@ -130,10 +132,7 @@ function getTourId(pathname: string): string | null {
// keeps the test-tube outline + horizontal cap + liquid line, dropping // keeps the test-tube outline + horizontal cap + liquid line, dropping
// the bubbles. The original export stays untouched, and HugeiconsIcon // the bubbles. The original export stays untouched, and HugeiconsIcon
// renders this trimmed array exactly the same way. // renders this trimmed array exactly the same way.
const TestTubeOutlineIcon = TestTube01Icon.slice( const TestTubeOutlineIcon = TestTube01Icon.slice(0, 3) as typeof TestTube01Icon;
0,
3,
) as typeof TestTube01Icon;
function runStatusDotClass(status: TrainingRunSummary["status"]): string { function runStatusDotClass(status: TrainingRunSummary["status"]): string {
switch (status) { switch (status) {
@ -199,8 +198,14 @@ function NavItem({
data-tour={dataTour} data-tour={dataTour}
className="sidebar-nav-btn h-[35px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto" className="sidebar-nav-btn h-[35px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto"
> >
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop" /> <HugeiconsIcon
<span className="text-[14.5px] leading-[19px] tracking-nav">{label}</span> icon={icon}
strokeWidth={1.75}
className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop"
/>
<span className="text-[14.5px] leading-[19px] tracking-nav">
{label}
</span>
</SidebarMenuButton> </SidebarMenuButton>
</div> </div>
{children} {children}
@ -230,17 +235,33 @@ export function AppSidebar() {
const [shutdownOpen, setShutdownOpen] = useState(false); const [shutdownOpen, setShutdownOpen] = useState(false);
const isChatRoute = pathname.startsWith("/chat"); const isChatRoute = pathname.startsWith("/chat");
const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/"); const isStudioRoute =
pathname === "/studio" || pathname.startsWith("/studio/");
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [canScrollDown, setCanScrollDown] = useState(false);
useEffect(() => { useEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
const handler = () => setScrolled(el.scrollTop > 0);
handler(); const updateScrollState = () => {
el.addEventListener("scroll", handler, { passive: true }); setScrolled(el.scrollTop > 0);
return () => el.removeEventListener("scroll", handler); setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1);
};
updateScrollState();
el.addEventListener("scroll", updateScrollState, { passive: true });
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(el);
const mutationObserver = new MutationObserver(updateScrollState);
mutationObserver.observe(el, { childList: true, subtree: true });
return () => {
el.removeEventListener("scroll", updateScrollState);
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, []); }, []);
const isRecipesRoute = pathname.startsWith("/data-recipes"); const isRecipesRoute = pathname.startsWith("/data-recipes");
@ -250,10 +271,10 @@ export function AppSidebar() {
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId); const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId);
const activeThreadId = isChatRoute const activeThreadId = isChatRoute
? (search.thread as string | undefined) ?? ? ((search.thread as string | undefined) ??
(search.compare as string | undefined) ?? (search.compare as string | undefined) ??
storeThreadId ?? storeThreadId ??
undefined undefined)
: undefined; : undefined;
// Training runs // Training runs
@ -261,12 +282,18 @@ export function AppSidebar() {
!chatOnly && isStudioRoute, !chatOnly && isStudioRoute,
); );
const activeJobId = useTrainingRuntimeStore((s) => s.jobId); const activeJobId = useTrainingRuntimeStore((s) => s.jobId);
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId); const selectedHistoryRunId = useTrainingRuntimeStore(
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId); (s) => s.selectedHistoryRunId,
);
const setSelectedHistoryRunId = useTrainingRuntimeStore(
(s) => s.setSelectedHistoryRunId,
);
const chatDisabled = isTrainingRunning; const chatDisabled = isTrainingRunning;
async function handleDeleteThread(item: Parameters<typeof deleteChatItem>[0]) { async function handleDeleteThread(
item: Parameters<typeof deleteChatItem>[0],
) {
await deleteChatItem(item, activeThreadId, (view) => { await deleteChatItem(item, activeThreadId, (view) => {
navigate({ navigate({
to: "/chat", to: "/chat",
@ -316,7 +343,10 @@ export function AppSidebar() {
return; return;
} }
try { try {
const updated = await renameTrainingRun(target.run.id, nextRunDisplayName); const updated = await renameTrainingRun(
target.run.id,
nextRunDisplayName,
);
emitTrainingRunUpdated(updated); emitTrainingRunUpdated(updated);
} catch (err) { } catch (err) {
toast.error(translate("shell.toast.failedToRenameRun"), { toast.error(translate("shell.toast.failedToRenameRun"), {
@ -328,8 +358,9 @@ export function AppSidebar() {
type DeleteTarget = type DeleteTarget =
| { kind: "chat"; item: SidebarItem } | { kind: "chat"; item: SidebarItem }
| { kind: "run"; run: TrainingRunSummary }; | { kind: "run"; run: TrainingRunSummary };
const [confirmingDelete, setConfirmingDelete] = const [confirmingDelete, setConfirmingDelete] = useState<DeleteTarget | null>(
useState<DeleteTarget | null>(null); null,
);
async function commitDelete() { async function commitDelete() {
const target = confirmingDelete; const target = confirmingDelete;
@ -408,7 +439,11 @@ export function AppSidebar() {
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.closeSidebar")} aria-label={t("shell.aria.closeSidebar")}
> >
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" /> <HugeiconsIcon
icon={LayoutAlignLeftIcon}
strokeWidth={1.75}
className="size-icon"
/>
</button> </button>
</TooltipPrimitive.Trigger> </TooltipPrimitive.Trigger>
<TooltipContent <TooltipContent
@ -433,7 +468,11 @@ export function AppSidebar() {
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.openSidebar")} aria-label={t("shell.aria.openSidebar")}
> >
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" /> <HugeiconsIcon
icon={LayoutAlignLeftIcon}
strokeWidth={1.75}
className="size-icon"
/>
</button> </button>
</TooltipPrimitive.Trigger> </TooltipPrimitive.Trigger>
<TooltipContent <TooltipContent
@ -448,7 +487,12 @@ export function AppSidebar() {
)} )}
</SidebarHeader> </SidebarHeader>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[8px] shrink-0"> <SidebarGroup
className={cn(
"app-sidebar-top-actions group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[8px] shrink-0",
scrolled && "is-scrolled",
)}
>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<NavItem <NavItem
@ -459,20 +503,10 @@ export function AppSidebar() {
onClick={() => { onClick={() => {
if (chatDisabled) return; if (chatDisabled) return;
setActiveThreadId(null); setActiveThreadId(null);
navigate({ to: "/chat", search: { new: createNavigationNonce() } }); navigate({
closeMobileIfOpen(); to: "/chat",
}} search: { new: createNavigationNonce() },
/> });
<NavItem
icon={ColumnInsertIcon}
label={t("shell.navigation.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: createNavigationNonce() } });
closeMobileIfOpen(); closeMobileIfOpen();
}} }}
/> />
@ -491,13 +525,26 @@ export function AppSidebar() {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[20px] shrink-0"> <SidebarContent
ref={scrollRef}
className={cn(
"app-sidebar-scroll-region gap-0 overflow-y-auto overscroll-contain min-h-0",
scrolled && "is-scrolled",
canScrollDown && "can-scroll-down",
)}
>
<SidebarGroup
data-tour="navbar"
className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[20px]"
>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<NavItem <NavItem
icon={TestTubeOutlineIcon} icon={TestTubeOutlineIcon}
label={t("shell.navigation.train")} label={t("shell.navigation.train")}
active={pathname === "/studio" || pathname.startsWith("/studio/")} active={
pathname === "/studio" || pathname.startsWith("/studio/")
}
disabled={chatOnly} disabled={chatOnly}
onClick={() => { onClick={() => {
if (chatOnly) return; if (chatOnly) return;
@ -519,7 +566,9 @@ export function AppSidebar() {
<NavItem <NavItem
icon={DownloadSquare01Icon} icon={DownloadSquare01Icon}
label={t("shell.navigation.export")} label={t("shell.navigation.export")}
active={pathname === "/export" || pathname.startsWith("/export/")} active={
pathname === "/export" || pathname.startsWith("/export/")
}
disabled={chatOnly} disabled={chatOnly}
onClick={() => { onClick={() => {
if (chatOnly) return; if (chatOnly) return;
@ -531,7 +580,6 @@ export function AppSidebar() {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<SidebarContent ref={scrollRef} className="gap-0 overflow-y-auto overscroll-contain min-h-0">
{!isStudioRoute && chatItems.length > 0 && ( {!isStudioRoute && chatItems.length > 0 && (
<Collapsible <Collapsible
key={isChatRoute ? "chat-route" : "non-chat-route"} key={isChatRoute ? "chat-route" : "non-chat-route"}
@ -539,7 +587,13 @@ export function AppSidebar() {
asChild asChild
> >
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0"> <SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild> <SidebarGroupLabel
className={cn(
"sidebar-sticky-label",
scrolled && "is-scrolled",
)}
asChild
>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between"> <CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
{t("shell.navigation.recents")} {t("shell.navigation.recents")}
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" /> <ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
@ -549,7 +603,10 @@ export function AppSidebar() {
<SidebarGroupContent className="px-2"> <SidebarGroupContent className="px-2">
<SidebarMenu> <SidebarMenu>
{chatItems.map((item) => ( {chatItems.map((item) => (
<SidebarMenuItem key={item.id} className="group/recent-item relative"> <SidebarMenuItem
key={item.id}
className="group/recent-item relative"
>
<SidebarMenuButton <SidebarMenuButton
data-testid="recent-thread" data-testid="recent-thread"
data-thread-type={item.type} data-thread-type={item.type}
@ -578,25 +635,40 @@ export function AppSidebar() {
className="sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto" className="sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
> >
<span className="sidebar-row-action-glyph"> <span className="sidebar-row-action-glyph">
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" /> <MoreHorizontalIcon
strokeWidth={1.75}
className="size-icon"
/>
</span> </span>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="bottom" side="bottom"
align="end" align="start"
sideOffset={4} sideOffset={4}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0" className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
> >
<DropdownMenuItem onSelect={() => openRenameChat(item)}> <DropdownMenuItem
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" /> onSelect={() => openRenameChat(item)}
>
<HugeiconsIcon
icon={Edit03Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{t("common.rename")}</span> <span>{t("common.rename")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })} onSelect={() =>
setConfirmingDelete({ kind: "chat", item })
}
> >
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" /> <HugeiconsIcon
icon={Delete02Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{t("common.delete")}</span> <span>{t("common.delete")}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@ -613,7 +685,13 @@ export function AppSidebar() {
{isStudioRoute && runItems.length > 0 && !chatOnly && ( {isStudioRoute && runItems.length > 0 && !chatOnly && (
<Collapsible key="studio-runs-route" defaultOpen asChild> <Collapsible key="studio-runs-route" defaultOpen asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0"> <SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild> <SidebarGroupLabel
className={cn(
"sidebar-sticky-label",
scrolled && "is-scrolled",
)}
asChild
>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between"> <CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
{t("shell.navigation.recents")} {t("shell.navigation.recents")}
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" /> <ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
@ -624,7 +702,8 @@ export function AppSidebar() {
<SidebarMenu> <SidebarMenu>
{runItems.map((run) => { {runItems.map((run) => {
const isActiveRun = const isActiveRun =
selectedHistoryRunId === run.id || activeJobId === run.id; selectedHistoryRunId === run.id ||
activeJobId === run.id;
return ( return (
<SidebarMenuItem <SidebarMenuItem
key={run.id} key={run.id}
@ -666,18 +745,27 @@ export function AppSidebar() {
className="sidebar-row-action group-hover/run-item:opacity-100 group-hover/run-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto" className="sidebar-row-action group-hover/run-item:opacity-100 group-hover/run-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
> >
<span className="sidebar-row-action-glyph"> <span className="sidebar-row-action-glyph">
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" /> <MoreHorizontalIcon
strokeWidth={1.75}
className="size-icon"
/>
</span> </span>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="bottom" side="bottom"
align="end" align="start"
sideOffset={4} sideOffset={4}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0" className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
> >
<DropdownMenuItem onSelect={() => openRenameRun(run)}> <DropdownMenuItem
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" /> onSelect={() => openRenameRun(run)}
>
<HugeiconsIcon
icon={Edit03Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{t("common.rename")}</span> <span>{t("common.rename")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
@ -687,7 +775,11 @@ export function AppSidebar() {
setConfirmingDelete({ kind: "run", run }) setConfirmingDelete({ kind: "run", run })
} }
> >
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" /> <HugeiconsIcon
icon={Delete02Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{t("common.delete")}</span> <span>{t("common.delete")}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@ -703,7 +795,12 @@ export function AppSidebar() {
)} )}
</SidebarContent> </SidebarContent>
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-transparent group-data-[collapsible=icon]:px-0"> <SidebarFooter
className={cn(
"app-sidebar-footer group-data-[collapsible=icon]:px-0",
canScrollDown && "can-scroll-down",
)}
>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <DropdownMenu>
@ -722,29 +819,48 @@ export function AppSidebar() {
/> />
</div> </div>
<div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden"> <div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">{displayTitle}</span> <span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span> {displayTitle}
</span>
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">
Unsloth
</span>
</div> </div>
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" /> <ChevronsUpDown
strokeWidth={1.25}
className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden"
/>
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
align="start" align="start"
className="app-user-menu menu-soft-surface-up ring-0 w-[15rem] py-2.5 font-heading rounded-[14px] border-0" className="app-user-menu menu-soft-surface-up ring-0 py-2.5 font-heading rounded-[14px] border-0"
> >
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog()} onSelect={() =>
useSettingsDialogStore.getState().openDialog()
}
> >
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-icon" /> <HugeiconsIcon
icon={Settings02Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{t("shell.navigation.settings")}</span> <span>{t("shell.navigation.settings")}</span>
<DropdownMenuShortcut>,</DropdownMenuShortcut> <DropdownMenuShortcut>,</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog("api-keys")} onSelect={() =>
useSettingsDialogStore.getState().openDialog("api-keys")
}
> >
<HugeiconsIcon icon={Globe02Icon} strokeWidth={1.75} className="size-[18px]" /> <HugeiconsIcon
icon={Globe02Icon}
strokeWidth={1.75}
className="size-[18px]"
/>
<span>{t("shell.navigation.api")}</span> <span>{t("shell.navigation.api")}</span>
<span className="ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300"> <span className="ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{t("common.new")} {t("common.new")}
@ -752,9 +868,16 @@ export function AppSidebar() {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
ref={anchorRef as React.Ref<HTMLDivElement>} ref={anchorRef as React.Ref<HTMLDivElement>}
onSelect={(e) => { e.preventDefault(); toggleTheme(); }} onSelect={(e) => {
e.preventDefault();
toggleTheme();
}}
> >
{isDark ? <Sun strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />} {isDark ? (
<Sun strokeWidth={1.75} className="size-icon" />
) : (
<Moon strokeWidth={1.75} className="size-icon" />
)}
<span> <span>
{isDark {isDark
? t("shell.navigation.lightMode") ? t("shell.navigation.lightMode")
@ -773,15 +896,25 @@ export function AppSidebar() {
); );
}} }}
> >
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" /> <HugeiconsIcon
icon={CursorInfo02Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{t("shell.navigation.guidedTour")}</span> <span>{t("shell.navigation.guidedTour")}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" /> <DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
<DropdownMenuItem <DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog("about")} onSelect={() =>
useSettingsDialogStore.getState().openDialog("about")
}
> >
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" /> <HugeiconsIcon
icon={HelpCircleIcon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{t("common.help")}</span> <span>{t("common.help")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
@ -796,11 +929,19 @@ export function AppSidebar() {
void navigate({ to: "/login" }); void navigate({ to: "/login" });
}} }}
> >
<HugeiconsIcon icon={Logout01Icon} strokeWidth={1.75} className="size-icon" /> <HugeiconsIcon
icon={Logout01Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{t("shell.navigation.logOut")}</span> <span>{t("shell.navigation.logOut")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}> <DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" /> <HugeiconsIcon
icon={PowerIcon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{t("common.shutdown")}</span> <span>{t("common.shutdown")}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@ -829,20 +970,20 @@ export function AppSidebar() {
: t("shell.dialog.deleteChat.title")} : t("shell.dialog.deleteChat.title")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{confirmingDelete?.kind === "run" ? ( {confirmingDelete?.kind === "run"
renderEmphasizedTranslation( ? renderEmphasizedTranslation(
t, t,
"shell.dialog.deleteRun.description", "shell.dialog.deleteRun.description",
confirmingDelete.run.display_name ?? confirmingDelete.run.display_name ??
confirmingDelete.run.model_name, confirmingDelete.run.model_name,
) )
) : confirmingDelete?.kind === "chat" ? ( : confirmingDelete?.kind === "chat"
renderEmphasizedTranslation( ? renderEmphasizedTranslation(
t, t,
"shell.dialog.deleteChat.description", "shell.dialog.deleteChat.description",
confirmingDelete.item.title, confirmingDelete.item.title,
) )
) : null} : null}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="flex-wrap gap-2 sm:justify-end"> <DialogFooter className="flex-wrap gap-2 sm:justify-end">

View file

@ -10,7 +10,6 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { usePlatformStore } from "@/config/env"; import { usePlatformStore } from "@/config/env";
import { isCustomProviderType } from "@/features/chat/external-providers";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
ArrowDown01Icon, ArrowDown01Icon,
@ -32,6 +31,22 @@ import type {
import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers"; import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
const CUSTOM_PROVIDER_TYPES_WITH_FALLBACK_LOGO = new Set([
"custom",
"llama_cpp",
"vllm",
"ollama",
]);
function isCustomProviderTypeWithFallbackLogo(
providerType: string | undefined,
): boolean {
return (
providerType !== undefined &&
CUSTOM_PROVIDER_TYPES_WITH_FALLBACK_LOGO.has(providerType)
);
}
const PROVIDER_LOGO_EXT: Record<string, "svg" | "png" | "jpg"> = { const PROVIDER_LOGO_EXT: Record<string, "svg" | "png" | "jpg"> = {
openai: "svg", openai: "svg",
mistral: "svg", mistral: "svg",
@ -64,7 +79,7 @@ function ExternalProviderLogo({
title?: string; title?: string;
}) { }) {
const src = providerLogoSrc(providerType); const src = providerLogoSrc(providerType);
if (!src && isCustomProviderType(providerType)) { if (!src && isCustomProviderTypeWithFallbackLogo(providerType)) {
return ( return (
<span title={title} aria-hidden={true} className="inline-flex shrink-0"> <span title={title} aria-hidden={true} className="inline-flex shrink-0">
<HugeiconsIcon <HugeiconsIcon
@ -146,10 +161,11 @@ function ModelSelectorTrigger({
type="button" type="button"
data-tour={dataTour} data-tour={dataTour}
className={cn( className={cn(
"flex min-w-0 items-center gap-2 transition-colors", "flex min-w-0 cursor-pointer items-center gap-2 transition-colors",
variant === "outline" && variant === "outline" &&
"rounded-[10px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", "rounded-[10px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
variant === "ghost" && "rounded-[10px] hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", variant === "ghost" &&
"rounded-[10px] hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
variant === "muted" && "rounded-[10px] bg-muted hover:bg-muted/80", variant === "muted" && "rounded-[10px] bg-muted hover:bg-muted/80",
size === "sm" && "h-8 px-3 text-xs", size === "sm" && "h-8 px-3 text-xs",
size === "default" && "h-9 px-3.5 text-sm", size === "default" && "h-9 px-3.5 text-sm",
@ -161,7 +177,9 @@ function ModelSelectorTrigger({
<span className="size-2 shrink-0 rounded-full bg-emerald-500" /> <span className="size-2 shrink-0 rounded-full bg-emerald-500" />
)} )}
{currentModel?.icon ? ( {currentModel?.icon ? (
<span className="flex shrink-0 items-center">{currentModel.icon}</span> <span className="flex shrink-0 items-center">
{currentModel.icon}
</span>
) : null} ) : null}
<span className="flex min-w-0 flex-1 items-baseline"> <span className="flex min-w-0 flex-1 items-baseline">
<span className="min-w-0 flex flex-1 items-baseline truncate font-heading text-[16px] font-medium leading-tight text-black dark:text-white"> <span className="min-w-0 flex flex-1 items-baseline truncate font-heading text-[16px] font-medium leading-tight text-black dark:text-white">
@ -228,7 +246,10 @@ function ModelSelectorContent({
const chatOnly = usePlatformStore((s) => s.isChatOnly()); const chatOnly = usePlatformStore((s) => s.isChatOnly());
const hasExternal = externalModels.length > 0; const hasExternal = externalModels.length > 0;
const chatOnlyTabsDefault = useMemo( const chatOnlyTabsDefault = useMemo(
() => (value && externalModels.some((model) => model.id === value) ? "external" : "hub"), () =>
value && externalModels.some((model) => model.id === value)
? "external"
: "hub",
[externalModels, value], [externalModels, value],
); );
const studioTabsDefault = useMemo((): "hub" | "lora" | "external" => { const studioTabsDefault = useMemo((): "hub" | "lora" | "external" => {
@ -246,7 +267,7 @@ function ModelSelectorContent({
align="start" align="start"
data-tour={dataTour} data-tour={dataTour}
className={cn( className={cn(
"menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2", "model-selector-menu menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2",
className, className,
)} )}
> >
@ -258,7 +279,12 @@ function ModelSelectorContent({
<TabsTrigger value="external">Connected</TabsTrigger> <TabsTrigger value="external">Connected</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="hub" className="m-0"> <TabsContent value="hub" className="m-0">
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} /> <HubModelPicker
models={models}
value={value}
onSelect={onSelect}
onFoldersChange={onFoldersChange}
/>
</TabsContent> </TabsContent>
<TabsContent value="external" className="m-0"> <TabsContent value="external" className="m-0">
<ExternalModelPicker <ExternalModelPicker
@ -269,18 +295,30 @@ function ModelSelectorContent({
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) : ( ) : (
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} /> <HubModelPicker
models={models}
value={value}
onSelect={onSelect}
onFoldersChange={onFoldersChange}
/>
) )
) : ( ) : (
<Tabs defaultValue={studioTabsDefault} className="w-full"> <Tabs defaultValue={studioTabsDefault} className="w-full">
<TabsList className="mb-2 w-full"> <TabsList className="mb-2 w-full">
<TabsTrigger value="hub">Hub models</TabsTrigger> <TabsTrigger value="hub">Hub models</TabsTrigger>
<TabsTrigger value="lora">Fine-tuned</TabsTrigger> <TabsTrigger value="lora">Fine-tuned</TabsTrigger>
{hasExternal ? <TabsTrigger value="external">Connected</TabsTrigger> : null} {hasExternal ? (
<TabsTrigger value="external">Connected</TabsTrigger>
) : null}
</TabsList> </TabsList>
<TabsContent value="hub" className="m-0"> <TabsContent value="hub" className="m-0">
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} /> <HubModelPicker
models={models}
value={value}
onSelect={onSelect}
onFoldersChange={onFoldersChange}
/>
</TabsContent> </TabsContent>
<TabsContent value="lora" className="m-0"> <TabsContent value="lora" className="m-0">
@ -420,7 +458,9 @@ export function ModelSelector({
const found = optionById.get(selected); const found = optionById.get(selected);
if (activeGgufVariant) { if (activeGgufVariant) {
const desc = `GGUF · ${activeGgufVariant}`; const desc = `GGUF · ${activeGgufVariant}`;
return found ? { ...found, description: desc } : { id: selected, name: selected, description: desc }; return found
? { ...found, description: desc }
: { id: selected, name: selected, description: desc };
} }
return found ?? { id: selected, name: selected }; return found ?? { id: selected, name: selected };
}, [selected, optionById, activeGgufVariant]); }, [selected, optionById, activeGgufVariant]);

File diff suppressed because it is too large Load diff

View file

@ -30,7 +30,7 @@ import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
const noop = () => {} const noop = () => {}
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@ -305,9 +305,8 @@ function Sidebar({
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar-inner" data-slot="sidebar-inner"
className={cn( className={cn(
"bg-sidebar flex size-full flex-col overflow-hidden border-r border-sidebar-border", "bg-sidebar flex size-full flex-col overflow-hidden",
"group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1", "group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1",
hasPinMode && "ring-1 ring-sidebar-border/60",
)} )}
> >
{children} {children}

View file

@ -63,7 +63,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
{ {
"--normal-bg": "var(--popover)", "--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)", "--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)", // No border line; elevation comes from the composer's drop shadow.
"--normal-border": "transparent",
"--border-radius": "var(--radius)", "--border-radius": "var(--radius)",
// Pin close button to the top-right corner inside the toast. // Pin close button to the top-right corner inside the toast.
// Overrides sonner's default left placement and outside-corner // Overrides sonner's default left placement and outside-corner

View file

@ -12,13 +12,15 @@ import {
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip"; import {
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay"; NativeModelChip,
import { useNativeIntentStore } from "@/features/native-intents/store"; NativeModelDropOverlay,
import type { NativeIntent } from "@/features/native-intents/types"; type NativeIntent,
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs"; useChooseNativeModel,
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop"; useNativeIntentStore,
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness"; useNativeModelDrop,
useNativePathLeasesSupported,
} from "@/features/native-intents";
import { GuidedTour, useGuidedTourController } from "@/features/tour"; import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base"; import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -85,6 +87,7 @@ import {
listStoredChatMessages, listStoredChatMessages,
listStoredChatThreads, listStoredChatThreads,
} from "./utils/chat-history-storage"; } from "./utils/chat-history-storage";
import { createCompareId } from "./utils/compare-id";
type LoraCandidate = { type LoraCandidate = {
id: string; id: string;
@ -156,7 +159,14 @@ function messageHasImage(message: MessageRecord): boolean {
const SingleContent = memo(function SingleContent({ const SingleContent = memo(function SingleContent({
threadId, threadId,
newThreadNonce, newThreadNonce,
}: { threadId?: string; newThreadNonce?: string }): ReactElement { canCompare,
onEnterCompare,
}: {
threadId?: string;
newThreadNonce?: string;
canCompare?: boolean;
onEnterCompare?: () => void;
}): ReactElement {
return ( return (
<ChatRuntimeProvider <ChatRuntimeProvider
modelType="base" modelType="base"
@ -164,7 +174,12 @@ const SingleContent = memo(function SingleContent({
newThreadNonce={newThreadNonce} newThreadNonce={newThreadNonce}
> >
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"> <div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} /> <Thread
hideWelcome={Boolean(threadId)}
targetThreadId={threadId}
canCompare={canCompare}
onEnterCompare={onEnterCompare}
/>
</div> </div>
</ChatRuntimeProvider> </ChatRuntimeProvider>
); );
@ -207,6 +222,7 @@ const CompareContent = memo(function CompareContent({
onFoldersChange, onFoldersChange,
onModelsChange, onModelsChange,
deleteDisabled, deleteDisabled,
onExitCompare,
}: { }: {
pairId: string; pairId: string;
models: ModelOption[]; models: ModelOption[];
@ -214,11 +230,12 @@ const CompareContent = memo(function CompareContent({
onFoldersChange?: () => void; onFoldersChange?: () => void;
onModelsChange?: (deletedModel?: DeletedModelRef) => void; onModelsChange?: (deletedModel?: DeletedModelRef) => void;
deleteDisabled?: boolean; deleteDisabled?: boolean;
onExitCompare?: () => void;
}): ReactElement { }): ReactElement {
const isLoraCompare = useIsLoraCompare(); const isLoraCompare = useIsLoraCompare();
return isLoraCompare ? ( return isLoraCompare ? (
<LoraCompareContent pairId={pairId} /> <LoraCompareContent pairId={pairId} onExitCompare={onExitCompare} />
) : ( ) : (
<GeneralCompareContent <GeneralCompareContent
pairId={pairId} pairId={pairId}
@ -227,6 +244,7 @@ const CompareContent = memo(function CompareContent({
onFoldersChange={onFoldersChange} onFoldersChange={onFoldersChange}
onModelsChange={onModelsChange} onModelsChange={onModelsChange}
deleteDisabled={deleteDisabled} deleteDisabled={deleteDisabled}
onExitCompare={onExitCompare}
/> />
); );
}); });
@ -311,7 +329,7 @@ function CompareShell({
{children} {children}
</div> </div>
<div className="shrink-0 bg-background pl-5 pr-5 md:pr-[30px] pb-2 pt-1"> <div className="shrink-0 bg-background pl-5 pr-5 md:pr-[30px] pb-2 pt-1">
<div className="mx-auto w-full max-w-[48rem]">{composer}</div> <div className="mx-auto w-full max-w-[660px]">{composer}</div>
<p className="composer-footer-note"> <p className="composer-footer-note">
LLMs can make mistakes. Double-check responses. LLMs can make mistakes. Double-check responses.
</p> </p>
@ -324,18 +342,21 @@ function CompareShell({
/** Fast path: same model, adapter on/off, simultaneous generation. */ /** Fast path: same model, adapter on/off, simultaneous generation. */
const LoraCompareContent = memo(function LoraCompareContent({ const LoraCompareContent = memo(function LoraCompareContent({
pairId, pairId,
}: { pairId: string }): ReactElement { onExitCompare,
}: { pairId: string; onExitCompare?: () => void }): ReactElement {
const handlesRef = useRef<Record<string, CompareHandle>>({}); const handlesRef = useRef<Record<string, CompareHandle>>({});
const [baseThreadId, setBaseThreadId] = useState<string>(); const [baseThreadId, setBaseThreadId] = useState<string>();
const [loraThreadId, setLoraThreadId] = useState<string>(); const [loraThreadId, setLoraThreadId] = useState<string>();
useEffect(() => { useEffect(() => {
let isActive = true; let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => { listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return; if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id); setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id); setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
}).catch((error) => { })
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) { if (!isExpectedBackgroundChatStorageError(error)) {
throw error; throw error;
} }
@ -348,7 +369,9 @@ const LoraCompareContent = memo(function LoraCompareContent({
return ( return (
<CompareShell <CompareShell
handlesRef={handlesRef} handlesRef={handlesRef}
composer={<SharedComposer handlesRef={handlesRef} />} composer={
<SharedComposer handlesRef={handlesRef} onExitCompare={onExitCompare} />
}
> >
<> <>
<ComparePane <ComparePane
@ -441,6 +464,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
onFoldersChange, onFoldersChange,
onModelsChange, onModelsChange,
deleteDisabled, deleteDisabled,
onExitCompare,
}: { }: {
pairId: string; pairId: string;
models: ModelOption[]; models: ModelOption[];
@ -448,6 +472,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
onFoldersChange?: () => void; onFoldersChange?: () => void;
onModelsChange?: (deletedModel?: DeletedModelRef) => void; onModelsChange?: (deletedModel?: DeletedModelRef) => void;
deleteDisabled?: boolean; deleteDisabled?: boolean;
onExitCompare?: () => void;
}): ReactElement { }): ReactElement {
const handlesRef = useRef<Record<string, CompareHandle>>({}); const handlesRef = useRef<Record<string, CompareHandle>>({});
const [model1ThreadId, setModel1ThreadId] = useState<string>(); const [model1ThreadId, setModel1ThreadId] = useState<string>();
@ -480,17 +505,21 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
useEffect(() => { useEffect(() => {
let isActive = true; let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => { listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return; if (!isActive) return;
setModel1ThreadId( setModel1ThreadId(
threads.find((t) => t.modelType === "model1" || t.modelType === "base") threads.find(
?.id, (t) => t.modelType === "model1" || t.modelType === "base",
)?.id,
); );
setModel2ThreadId( setModel2ThreadId(
threads.find((t) => t.modelType === "model2" || t.modelType === "lora") threads.find(
?.id, (t) => t.modelType === "model2" || t.modelType === "lora",
)?.id,
); );
}).catch((error) => { })
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) { if (!isExpectedBackgroundChatStorageError(error)) {
throw error; throw error;
} }
@ -508,6 +537,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
handlesRef={handlesRef} handlesRef={handlesRef}
model1={model1} model1={model1}
model2={model2} model2={model2}
onExitCompare={onExitCompare}
/> />
} }
> >
@ -617,7 +647,9 @@ export function ChatPage(): ReactElement {
const [modelSelectorOpen, setModelSelectorOpen] = useState(false); const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false); const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const viewBeforeCompareRef = useRef<ChatSearch | null>(null); const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatSearch | null>(
null,
);
const inferenceParams = useChatRuntimeStore((state) => state.params); const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams); const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const activeGgufVariant = useChatRuntimeStore( const activeGgufVariant = useChatRuntimeStore(
@ -647,8 +679,7 @@ export function ChatPage(): ReactElement {
} = useChatModelRuntime(); } = useChatModelRuntime();
const prevConnectionsEnabledRef = useRef(connectionsEnabled); const prevConnectionsEnabledRef = useRef(connectionsEnabled);
useEffect(() => { useEffect(() => {
const turnedOff = const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
prevConnectionsEnabledRef.current && !connectionsEnabled;
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) { if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
clearCheckpoint(); clearCheckpoint();
if (turnedOff) { if (turnedOff) {
@ -658,11 +689,7 @@ export function ChatPage(): ReactElement {
} }
} }
prevConnectionsEnabledRef.current = connectionsEnabled; prevConnectionsEnabledRef.current = connectionsEnabled;
}, [ }, [clearCheckpoint, connectionsEnabled, inferenceParams.checkpoint]);
clearCheckpoint,
connectionsEnabled,
inferenceParams.checkpoint,
]);
const pendingNativeModelIntent = useNativeIntentStore( const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent, (state) => state.pendingModelIntent,
); );
@ -681,17 +708,19 @@ export function ChatPage(): ReactElement {
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled); const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle); const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort); const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff); const supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const activeExternalProvider = useMemo(() => { const activeExternalProvider = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint); const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null; if (!selection) return null;
return ( return (
externalProvidersForChat.find( externalProvidersForChat.find((p) => p.id === selection.providerId) ??
(p) => p.id === selection.providerId, null
) ?? null
); );
}, [externalProvidersForChat, inferenceParams.checkpoint]); }, [externalProvidersForChat, inferenceParams.checkpoint]);
const activeExternalProviderType = activeExternalProvider?.providerType ?? null; const activeExternalProviderType =
activeExternalProvider?.providerType ?? null;
const activeProviderCapabilities = useMemo(() => { const activeProviderCapabilities = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint); const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null; if (!selection) return null;
@ -807,7 +836,9 @@ export function ChatPage(): ReactElement {
(provider?.providerType === "anthropic" || (provider?.providerType === "anthropic" ||
provider?.providerType === "openai"); provider?.providerType === "openai");
const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY); const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY);
const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY); const storedCodeToolsEnabled = loadOptionalBool(
CHAT_CODE_TOOLS_ENABLED_KEY,
);
const storedImageToolsEnabled = loadOptionalBool( const storedImageToolsEnabled = loadOptionalBool(
CHAT_IMAGE_TOOLS_ENABLED_KEY, CHAT_IMAGE_TOOLS_ENABLED_KEY,
); );
@ -971,8 +1002,7 @@ export function ChatPage(): ReactElement {
selectedProvider?.providerType, selectedProvider?.providerType,
selectedExternal?.modelId, selectedExternal?.modelId,
{ {
isReasoningProvider: isReasoningProvider: selectedProvider?.isReasoningModel === true,
selectedProvider?.isReasoningModel === true,
baseUrl: selectedProvider?.baseUrl ?? null, baseUrl: selectedProvider?.baseUrl ?? null,
}, },
); );
@ -1018,7 +1048,8 @@ export function ChatPage(): ReactElement {
selectedExternal?.modelId, selectedExternal?.modelId,
selectedProvider?.baseUrl, selectedProvider?.baseUrl,
); );
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution( const supportsBuiltinCodeExecution =
providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType, selectedProvider?.providerType,
selectedExternal?.modelId, selectedExternal?.modelId,
selectedProvider?.baseUrl, selectedProvider?.baseUrl,
@ -1185,16 +1216,27 @@ export function ChatPage(): ReactElement {
const openSidebar = useCallback(() => setPinned(true), [setPinned]); const openSidebar = useCallback(() => setPinned(true), [setPinned]);
const enterCompare = useCallback(() => { const enterCompare = useCallback(() => {
viewBeforeCompareRef.current = { ...search }; const saved: ChatSearch = { ...search };
const active = useChatRuntimeStore.getState().activeThreadId;
if (!(saved.thread || saved.new) && active) {
if (!active.startsWith("__LOCALID_")) {
saved.thread = active;
}
}
setViewBeforeCompare(saved);
useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null); useChatRuntimeStore.getState().setContextUsage(null);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } }); navigate({ to: "/chat", search: { compare: createCompareId() } });
}, [navigate, search]); }, [navigate, search]);
const exitCompare = useCallback(() => { const exitCompare = useCallback(() => {
const saved = viewBeforeCompareRef.current; const saved = viewBeforeCompare;
if (!saved) return; // No saved view (compare opened by direct URL); fall back to a fresh chat.
viewBeforeCompareRef.current = null; if (!saved) {
navigate({ to: "/chat" });
return;
}
setViewBeforeCompare(null);
navigate({ to: "/chat", search: saved }); navigate({ to: "/chat", search: saved });
// Restore usage from the last assistant message, but only if it // Restore usage from the last assistant message, but only if it
// matches the currently active checkpoint. Without this guard the // matches the currently active checkpoint. Without this guard the
@ -1215,8 +1257,7 @@ export function ChatPage(): ReactElement {
if (!usage) return; if (!usage) return;
const store = useChatRuntimeStore.getState(); const store = useChatRuntimeStore.getState();
const activeCheckpoint = store.params.checkpoint; const activeCheckpoint = store.params.checkpoint;
const usageModelId = const usageModelId = (usage as { modelId?: unknown }).modelId;
(usage as { modelId?: unknown }).modelId;
// Scope by modelId when present; reject if no active checkpoint // Scope by modelId when present; reject if no active checkpoint
// (model-scoped usage cannot be attributed to "nothing"). // (model-scoped usage cannot be attributed to "nothing").
if (typeof usageModelId === "string" && usageModelId) { if (typeof usageModelId === "string" && usageModelId) {
@ -1242,7 +1283,7 @@ export function ChatPage(): ReactElement {
} }
}); });
} }
}, [navigate]); }, [navigate, viewBeforeCompare]);
const models = useMemo<ModelOption[]>( const models = useMemo<ModelOption[]>(
() => () =>
@ -1398,7 +1439,7 @@ export function ChatPage(): ReactElement {
if (canceled) return; if (canceled) return;
useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null); useChatRuntimeStore.getState().setContextUsage(null);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } }); navigate({ to: "/chat", search: { compare: createCompareId() } });
clearHandoff(); clearHandoff();
console.info("[chat-handoff] loaded lora + opened compare"); console.info("[chat-handoff] loaded lora + opened compare");
return; return;
@ -1596,6 +1637,8 @@ export function ChatPage(): ReactElement {
key={view.threadId ?? "single"} key={view.threadId ?? "single"}
threadId={view.threadId} threadId={view.threadId}
newThreadNonce={view.newThreadNonce} newThreadNonce={view.newThreadNonce}
canCompare={canCompare}
onEnterCompare={enterCompare}
/> />
) : ( ) : (
<CompareContent <CompareContent
@ -1606,6 +1649,7 @@ export function ChatPage(): ReactElement {
onFoldersChange={refreshLocalModels} onFoldersChange={refreshLocalModels}
onModelsChange={refreshModelLists} onModelsChange={refreshModelLists}
deleteDisabled={modelOperationInProgress} deleteDisabled={modelOperationInProgress}
onExitCompare={exitCompare}
/> />
)} )}
</div> </div>

View file

@ -58,7 +58,7 @@ import {
LayoutAlignRightIcon, LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown } from "lucide-react"; import { ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Fragment, type ReactNode } from "react"; import { Fragment, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
@ -327,11 +327,14 @@ function saveCollapsibleOpen(label: string, open: boolean) {
function CollapsibleSection({ function CollapsibleSection({
label, label,
labelHref,
children, children,
defaultOpen = false, defaultOpen = false,
first = false, first = false,
}: { }: {
label: string; label: string;
/** Optional link for the label (e.g. its PR); chevron still toggles. */
labelHref?: string;
children?: ReactNode; children?: ReactNode;
defaultOpen?: boolean; defaultOpen?: boolean;
first?: boolean; first?: boolean;
@ -341,6 +344,17 @@ function CollapsibleSection({
return Object.hasOwn(saved, label) ? saved[label] : defaultOpen; return Object.hasOwn(saved, label) ? saved[label] : defaultOpen;
}); });
const toggle = () => {
const next = !open;
setOpen(next);
saveCollapsibleOpen(label, next);
};
const headerClasses = cn(
"flex w-full items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors focus-visible:outline-none focus-visible:ring-0",
first ? "pt-4 pb-5" : "py-5",
);
return ( return (
<div <div
className={cn( className={cn(
@ -348,17 +362,33 @@ function CollapsibleSection({
"border-t border-black/[0.13] dark:border-white/[0.09]", "border-t border-black/[0.13] dark:border-white/[0.09]",
)} )}
> >
{labelHref ? (
<div className={headerClasses}>
<a
href={labelHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex cursor-pointer items-center gap-1 leading-none transition-colors hover:text-nav-fg"
>
<span>{label}</span>
<ExternalLink className="size-3" />
</a>
<button <button
type="button" type="button"
onClick={() => { onClick={toggle}
const next = !open; aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
setOpen(next); className="flex shrink-0 cursor-pointer items-center leading-none transition-colors hover:text-nav-fg"
saveCollapsibleOpen(label, next); >
}} <ChevronDown
className={cn( className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
"flex w-full cursor-pointer items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors hover:text-nav-fg focus-visible:outline-none focus-visible:ring-0", />
first ? "pt-4 pb-5" : "py-5", </button>
)} </div>
) : (
<button
type="button"
onClick={toggle}
className={cn("cursor-pointer hover:text-nav-fg", headerClasses)}
> >
<span className="leading-none">{label}</span> <span className="leading-none">{label}</span>
<span className="flex shrink-0 items-center leading-none"> <span className="flex shrink-0 items-center leading-none">
@ -367,6 +397,7 @@ function CollapsibleSection({
/> />
</span> </span>
</button> </button>
)}
{open && <div className="pb-7">{children}</div>} {open && <div className="pb-7">{children}</div>}
</div> </div>
); );
@ -1345,7 +1376,10 @@ export function ChatSettingsPanel({
) : null} ) : null}
{!isExternalModel ? ( {!isExternalModel ? (
<CollapsibleSection label="MCP Servers"> <CollapsibleSection
label="MCP Servers"
labelHref="https://github.com/unslothai/unsloth/pull/5852"
>
<McpServersSection /> <McpServersSection />
</CollapsibleSection> </CollapsibleSection>
) : null} ) : null}

View file

@ -88,7 +88,7 @@ export function ModelLoadDescription({
size="xs" size="xs"
variant="ghost" variant="ghost"
aria-label="Stop model loading" aria-label="Stop model loading"
className="h-auto self-stretch shrink-0 !rounded-none !border-0 bg-transparent px-1 text-[10px] text-muted-foreground hover:bg-transparent hover:text-destructive focus-visible:text-destructive" className="h-auto self-stretch shrink-0 !rounded-none !border-0 bg-transparent px-1 text-[10px] text-muted-foreground hover:!bg-transparent dark:hover:!bg-transparent hover:text-destructive focus-visible:text-destructive"
onClick={onStop} onClick={onStop}
> >
Cancel Cancel

View file

@ -2,6 +2,13 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { ChatPage } from "./chat-page"; export { ChatPage } from "./chat-page";
export { sentAudioNames } from "./api/chat-adapter";
export { parseExternalModelId } from "./external-providers";
export { getExternalReasoningCapabilities } from "./provider-capabilities";
export { useExternalProvidersStore } from "./stores/external-providers-store";
export { deleteThreadMessage } from "./utils/delete-thread-message";
export { createCompareId } from "./utils/compare-id";
export { applyQwenThinkingParams } from "./utils/qwen-params";
export { export {
getInferenceStatus, getInferenceStatus,
listGgufVariants, listGgufVariants,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export function createSafeId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function createCompareId(): string {
return createSafeId();
}

View file

@ -0,0 +1,8 @@
export { NativeModelChip } from "./components/native-model-chip";
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
export { useNativeIntentStore } from "./store";
export type { NativeIntent } from "./types";
export { useChooseNativeModel } from "./use-native-dialogs";
export { useNativeModelDrop } from "./use-native-drop";
export type { NativeModelDropState } from "./use-native-drop";
export { useNativePathLeasesSupported } from "./use-native-readiness";

View file

@ -41,7 +41,8 @@
@font-face { @font-face {
font-family: "Fira Code"; font-family: "Fira Code";
src: url("/fonts/FiraCode-VariableFont_wght.ttf") format("truetype-variations"); src: url("/fonts/FiraCode-VariableFont_wght.ttf")
format("truetype-variations");
font-weight: 300 700; font-weight: 300 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -224,11 +225,15 @@
--spacing: 0.25rem; --spacing: 0.25rem;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0); --shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0); --shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0); --shadow-sm: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px
hsl(0 0% 0% / 0);
--shadow: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0); --shadow: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px 0px hsl(0 0% 0% / 0); --shadow-md: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px 0px
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px hsl(0 0% 0% / 0); hsl(0 0% 0% / 0);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px hsl(0 0% 0% / 0); --shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px
hsl(0 0% 0% / 0);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px
hsl(0 0% 0% / 0);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0); --shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--nav-fg: #c7c7c4; --nav-fg: #c7c7c4;
@ -345,7 +350,6 @@
--animate-pulse: pulse var(--duration) ease-out infinite; --animate-pulse: pulse var(--duration) ease-out infinite;
@keyframes pulse { @keyframes pulse {
0%, 0%,
100% { 100% {
box-shadow: 0 0 0 0 var(--pulse-color); box-shadow: 0 0 0 0 var(--pulse-color);
@ -359,7 +363,6 @@
--animate-shiny-text: shiny-text 8s infinite; --animate-shiny-text: shiny-text 8s infinite;
@keyframes shiny-text { @keyframes shiny-text {
0%, 0%,
90%, 90%,
100% { 100% {
@ -443,7 +446,6 @@
} }
@layer utilities { @layer utilities {
/* Heading font utility the logo applies its own dark-mode tracking /* Heading font utility the logo applies its own dark-mode tracking
to offset optical bloom at large sizes. For small UI text (menus, to offset optical bloom at large sizes. For small UI text (menus,
nav items) we keep a single tight tracking in both themes, relying nav items) we keep a single tight tracking in both themes, relying
@ -486,9 +488,11 @@
.sidebar-nav-btn[data-active="true"], .sidebar-nav-btn[data-active="true"],
.sidebar-nav-btn[data-state="open"], .sidebar-nav-btn[data-state="open"],
.group\/recent-item:hover .sidebar-nav-btn, .group\/recent-item:hover .sidebar-nav-btn,
.group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn, .group\/recent-item:has(.sidebar-row-action[data-state="open"])
.sidebar-nav-btn,
.group\/run-item:hover .sidebar-nav-btn, .group\/run-item:hover .sidebar-nav-btn,
.group\/run-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn { .group\/run-item:has(.sidebar-row-action[data-state="open"])
.sidebar-nav-btn {
background-color: var(--nav-surface-hover) !important; background-color: var(--nav-surface-hover) !important;
color: #000 !important; color: #000 !important;
} }
@ -496,9 +500,13 @@
.dark .sidebar-nav-btn[data-active="true"], .dark .sidebar-nav-btn[data-active="true"],
.dark .sidebar-nav-btn[data-state="open"], .dark .sidebar-nav-btn[data-state="open"],
.dark .group\/recent-item:hover .sidebar-nav-btn, .dark .group\/recent-item:hover .sidebar-nav-btn,
.dark .group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn, .dark
.group\/recent-item:has(.sidebar-row-action[data-state="open"])
.sidebar-nav-btn,
.dark .group\/run-item:hover .sidebar-nav-btn, .dark .group\/run-item:hover .sidebar-nav-btn,
.dark .group\/run-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn { .dark
.group\/run-item:has(.sidebar-row-action[data-state="open"])
.sidebar-nav-btn {
color: #fff !important; color: #fff !important;
} }
@ -529,10 +537,88 @@
} }
.sidebar-sticky-label { .sidebar-sticky-label {
@apply sticky top-0 z-20 rounded-none bg-sidebar pt-0 pb-1.5 pl-[18px] pr-4 text-[13px]! font-medium normal-case tracking-[0.04em] text-nav-fg-muted focus-visible:ring-0! focus-visible:outline-none shadow-[0_-8px_0_0_var(--sidebar)] transition-shadow duration-150; @apply rounded-none bg-sidebar pt-0 pb-1.5 pl-[18px] pr-4 text-[13px]! font-medium normal-case tracking-[0.04em] text-nav-fg-muted focus-visible:ring-0! focus-visible:outline-none;
} }
.sidebar-sticky-label.is-scrolled {
@apply shadow-[0_-8px_0_0_var(--sidebar),0_0.5px_0_0_var(--sidebar-border)]; .app-sidebar-top-actions {
position: relative;
z-index: 25;
background: var(--sidebar);
}
.app-sidebar-top-actions::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -16px;
height: 16px;
pointer-events: none;
opacity: 0;
background: linear-gradient(to bottom, var(--sidebar) 0, transparent 100%);
transition: opacity var(--duration-fast) ease-out;
}
.app-sidebar-top-actions.is-scrolled::after {
opacity: 1;
}
.app-sidebar-scroll-region {
mask-repeat: no-repeat;
mask-size: 100% 100%;
transition: mask-image var(--duration-fast) ease-out;
}
.app-sidebar-scroll-region.is-scrolled {
mask-image: linear-gradient(to bottom, transparent 0, #000 18px, #000 100%);
}
.app-sidebar-scroll-region.can-scroll-down {
mask-image: linear-gradient(
to bottom,
#000 0,
#000 calc(100% - 18px),
transparent 100%
);
}
.app-sidebar-scroll-region.is-scrolled.can-scroll-down {
mask-image: linear-gradient(
to bottom,
transparent 0,
#000 18px,
#000 calc(100% - 18px),
transparent 100%
);
}
/* Firefox can box/clip native scrollbars when the scroll container is
masked. Keep the sidebar chrome clean there; the header/footer fade
overlays still provide the edge cue. */
@supports (-moz-appearance: none) {
.app-sidebar-scroll-region,
.app-sidebar-scroll-region.is-scrolled,
.app-sidebar-scroll-region.can-scroll-down,
.app-sidebar-scroll-region.is-scrolled.can-scroll-down {
mask-image: none;
}
}
.app-sidebar-footer {
position: relative;
z-index: 25;
background: var(--sidebar);
}
.app-sidebar-footer.can-scroll-down::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: -24px;
height: 24px;
pointer-events: none;
background: linear-gradient(to bottom, transparent 0, var(--sidebar) 100%);
} }
/* Neutral panel input surface sidesteps the green cast on /* Neutral panel input surface sidesteps the green cast on
@ -711,6 +797,9 @@
.dark .app-user-menu [data-slot="dropdown-menu-item"] { .dark .app-user-menu [data-slot="dropdown-menu-item"] {
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
.app-user-menu [data-slot="dropdown-menu-item"]:not([data-disabled]) {
cursor: pointer;
}
.app-user-menu [data-slot="dropdown-menu-item"] svg { .app-user-menu [data-slot="dropdown-menu-item"] svg {
width: 19px !important; width: 19px !important;
height: 19px !important; height: 19px !important;
@ -737,13 +826,17 @@
--destructive: #ed7878; --destructive: #ed7878;
} }
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"], .app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"],
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus { .app-user-menu
[data-slot="dropdown-menu-item"][data-variant="destructive"]:focus {
color: var(--destructive); color: var(--destructive);
} }
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus { .app-user-menu
[data-slot="dropdown-menu-item"][data-variant="destructive"]:focus {
background-color: color-mix(in oklab, var(--destructive) 10%, transparent); background-color: color-mix(in oklab, var(--destructive) 10%, transparent);
} }
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus * { .app-user-menu
[data-slot="dropdown-menu-item"][data-variant="destructive"]:focus
* {
color: var(--destructive) !important; color: var(--destructive) !important;
} }
@ -767,10 +860,9 @@
--menu-soft-blur: 28px; --menu-soft-blur: 28px;
--menu-soft-spread: -6px; --menu-soft-spread: -6px;
@apply bg-popover text-popover-foreground; @apply bg-popover text-popover-foreground;
box-shadow: box-shadow: inset 0 0 0 1px var(--menu-soft-edge), 0
inset 0 0 0 1px var(--menu-soft-edge), var(--menu-soft-offset-y) var(--menu-soft-blur) var(--menu-soft-spread)
0 var(--menu-soft-offset-y) var(--menu-soft-blur) var(--menu-soft-shadow);
var(--menu-soft-spread) var(--menu-soft-shadow);
} }
.menu-soft-surface-up { .menu-soft-surface-up {
--menu-soft-offset-y: -6px; --menu-soft-offset-y: -6px;
@ -782,35 +874,127 @@
--menu-soft-shadow: rgba(0, 0, 0, 0.28); --menu-soft-shadow: rgba(0, 0, 0, 0.28);
} }
.app-user-menu.menu-soft-surface,
.app-user-menu.menu-soft-surface-up {
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16);
}
.dark .app-user-menu.menu-soft-surface,
.dark .app-user-menu.menu-soft-surface-up {
box-shadow: 0 3px 12px -6px rgba(0, 0, 0, 0.46);
}
.model-selector-menu button:not(:disabled) {
cursor: pointer;
}
.chat-composer-surface { .chat-composer-surface {
@apply relative flex w-full flex-col rounded-[24px] bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow; @apply relative flex w-full flex-col rounded-[32px] bg-white dark:bg-card px-3 py-3 outline-none transition-shadow;
font-family: var(--font-sans); font-family: var(--font-sans);
border: 1px solid oklch(0.93 0 0 / 1);
background-clip: padding-box; background-clip: padding-box;
box-shadow: box-shadow: 0px 2px 8px -2px rgba(0, 0, 0, 0.16);
0 1px 2px oklch(0 0 0 / 0.04),
0 6px 14px oklch(0 0 0 / 0.05),
0 18px 40px oklch(0 0 0 / 0.05);
} }
.dark .chat-composer-surface { .dark .chat-composer-surface {
border-color: #34363a; background-color: #2a2a2c;
box-shadow: 0 -6px 36px -14px rgba(0, 0, 0, 0.15); box-shadow: none;
} }
.composer-pill-btn { .composer-pill-btn {
@apply flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40; @apply flex h-6 cursor-pointer items-center gap-1 rounded-full pl-1 pr-2 py-0 text-[14px] font-medium leading-6 text-muted-foreground/75 transition-colors hover:bg-primary/10 dark:hover:bg-primary/15 disabled:cursor-not-allowed disabled:opacity-40;
} }
.composer-pill-btn:is(:hover, :focus-visible) {
background-color: color-mix(in oklch, var(--primary) 10%, transparent);
color: var(--foreground);
}
.dark .composer-pill-btn:is(:hover, :focus-visible) {
background-color: color-mix(in oklch, var(--primary) 15%, transparent);
color: var(--foreground);
}
.composer-pill-btn[data-active="true"] { .composer-pill-btn[data-active="true"] {
color: var(--primary); color: var(--primary);
} }
.composer-pill-btn > span {
font-size: 13px;
font-weight: 400;
line-height: 17px;
}
.composer-pill-btn svg {
width: 16px !important;
height: 16px !important;
flex-shrink: 0;
}
.composer-pill-close {
display: none;
order: -1;
}
.composer-pill-btn[data-active="true"]:not(:disabled):is(
:hover,
:focus-visible
) {
background-color: color-mix(in oklch, var(--primary) 10%, transparent);
color: var(--primary);
}
.dark
.composer-pill-btn[data-active="true"]:not(:disabled):is(
:hover,
:focus-visible
) {
background-color: color-mix(in oklch, var(--primary) 15%, transparent);
color: var(--primary);
}
.composer-pill-btn[data-active="true"]:not(:disabled):is(
:hover,
:focus-visible
)
> svg:not(.composer-pill-close) {
display: none;
}
.composer-pill-btn[data-active="true"]:not(:disabled):is(
:hover,
:focus-visible
)
.composer-pill-close {
display: inline-flex;
}
.composer-send-enter {
animation: composer-send-enter 120ms var(--ease-out-quart) both;
}
@keyframes composer-send-enter {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.composer-send-enter {
animation: none;
}
}
.composer-input { .composer-input {
@apply mt-2 mb-1 mx-3 min-h-12 w-[calc(100%-1.5rem)] 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; @apply mb-1 min-h-10 w-full resize-none overflow-y-auto bg-transparent px-1.5 py-2 text-[15px] font-[450] leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0;
} }
.composer-action-wrapper { .composer-action-wrapper {
@apply relative mx-2 mb-2 flex items-center justify-between; @apply relative mt-1 flex items-center justify-between;
} }
.composer-footer-note { .composer-footer-note {
@ -818,6 +1002,252 @@
font-family: var(--font-sans); font-family: var(--font-sans);
} }
/* Pill composer; own classes so compare-mode keeps its stacked layout. */
.unsloth-composer-surface {
@apply relative flex w-full flex-col rounded-[32px] bg-white dark:bg-card px-3 py-3 outline-none transition-shadow;
font-family: var(--font-sans);
background-clip: padding-box;
box-shadow: 0px 2px 8px -2px rgba(0, 0, 0, 0.16);
}
.unsloth-composer-surface:focus-within {
box-shadow: 0px 2px 10px -2px rgba(0, 0, 0, 0.18);
}
.dark .unsloth-composer-surface,
.dark .unsloth-composer-surface:focus-within {
background-color: #2a2a2c;
box-shadow: none;
}
/* Keep the expand/collapse width swap instant. A transition on width (e.g.
the reduced-motion blanket rule) makes getComputedStyle().width lag a
frame, so autosize measures the stale width and leaves a stray blank row. */
.unsloth-composer-line,
.unsloth-composer-line .unsloth-composer-input,
.unsloth-composer-left {
transition-property: none !important;
}
/* Composer row: one centered line when empty; two rows (input over controls)
when filled, so the textarea never remounts. */
.unsloth-composer-line {
@apply flex w-full flex-wrap items-center gap-0.5 px-0;
}
.unsloth-composer-left {
@apply flex shrink-0 items-center gap-1;
order: 1;
/* Pull the plus button closer to the composer edge. */
margin-left: -0.25rem;
}
.unsloth-composer-line .unsloth-composer-input {
order: 2;
}
.unsloth-composer-line .aui-composer-action-wrapper {
order: 3;
margin-left: auto;
}
.unsloth-composer-line[data-expanded="true"] .unsloth-composer-input {
order: 1;
flex-basis: 100%;
width: 100%;
/* Sits close to the left edge, near the plus. */
padding-left: 0.375rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.unsloth-composer-line[data-expanded="true"] .unsloth-composer-left {
order: 2;
}
/* Empty (placeholder shown): clamp back to one row. max-height beats the
autosize textarea's inline !important height, so a cleared message leaves
no stale tall box or stray scrollbar. */
.unsloth-composer-input:placeholder-shown {
max-height: 40px !important;
overflow-y: hidden !important;
}
.unsloth-composer-input {
@apply min-h-[40px] min-w-0 flex-1 resize-none overflow-y-auto bg-transparent pl-0.5 pr-2 py-2 text-[15px] font-[450] leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0;
}
.unsloth-composer-plus {
@apply flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-foreground transition-colors hover:bg-muted-foreground/15 disabled:cursor-not-allowed disabled:opacity-40;
}
.unsloth-composer-plus svg {
/* 250ms reads better than --duration-normal for the plus/x morph. */
transition: transform 250ms ease-in-out;
transform-origin: center;
}
.unsloth-composer-plus[data-state="open"] {
/* Radix's modal menu sets body pointer-events:none; re-enable on the open
trigger so the cursor and click-to-close work. */
pointer-events: auto;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.08);
}
.dark .unsloth-composer-plus[data-state="open"] {
background-color: rgba(255, 255, 255, 0.08);
}
.unsloth-composer-plus[data-state="open"]:hover {
background-color: rgba(0, 0, 0, 0.08);
}
.dark .unsloth-composer-plus[data-state="open"]:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.unsloth-composer-plus[data-state="open"] svg {
transform: rotate(35deg);
}
/* Set Hellix explicitly; .aui-thread-root resets --font-heading to sans. */
.unsloth-welcome-title {
font-family: "Hellix", "Space Grotesk Variable", var(--font-sans);
font-weight: 500;
}
/* Right-side Thinking pill (toggle or dropdown). */
.unsloth-thinking-pill {
@apply inline-flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-full pl-1 pr-2 py-0 text-[14px] font-medium leading-6 text-muted-foreground/75 transition-colors hover:bg-primary/10 dark:hover:bg-primary/15 disabled:cursor-not-allowed disabled:opacity-40;
}
.unsloth-thinking-pill > span {
font-size: 13px;
font-weight: 400;
line-height: 17px;
}
.unsloth-thinking-pill svg {
width: 16px !important;
height: 16px !important;
flex-shrink: 0;
}
.unsloth-thinking-pill[data-active="true"] {
color: var(--primary);
/* See .unsloth-composer-plus: re-enable pointer-events on the open trigger. */
pointer-events: auto;
cursor: pointer;
}
.unsloth-thinking-pill[data-state="open"] {
background-color: rgba(0, 0, 0, 0.08);
color: var(--foreground);
pointer-events: auto;
cursor: pointer;
}
.unsloth-thinking-pill[data-active="true"][data-state="open"] {
color: var(--primary);
}
.dark .unsloth-thinking-pill[data-state="open"] {
background-color: rgba(255, 255, 255, 0.08);
color: var(--foreground);
}
.dark .unsloth-thinking-pill[data-active="true"][data-state="open"] {
color: var(--primary);
}
.unsloth-thinking-pill:is(:hover, :focus-visible) {
background-color: color-mix(in oklch, var(--primary) 10%, transparent);
color: var(--foreground);
}
.dark .unsloth-thinking-pill:is(:hover, :focus-visible) {
background-color: color-mix(in oklch, var(--primary) 15%, transparent);
color: var(--foreground);
}
.unsloth-thinking-pill[data-active="true"]:is(:hover, :focus-visible) {
color: var(--primary);
}
/* Smaller tick for selected Thinking options. */
.unsloth-tick {
width: 0.8rem !important;
height: 0.8rem !important;
}
/* Soft elevation; [data-slot] outranks the component ring-1, dropping the border. */
.unsloth-plus-menu[data-slot] {
/* Pin radius so dark matches light (rounded-lg resolves smaller in dark). */
border-radius: 18px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
box-shadow: 0 1px 2px oklch(0 0 0 / 0.04), 0 6px 18px oklch(0 0 0 / 0.08);
}
.dark .unsloth-plus-menu[data-slot] {
background-color: #2a2a2c;
/* Shadow tinted to the page bg so it blends, not a dark halo. */
box-shadow: 0 4px 14px var(--background);
}
/* Compact items; also applied to portaled sub-content. */
.unsloth-plus-menu
:is(
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-sub-trigger"]
) {
@apply gap-3 pl-4 pr-3 py-2 text-[14px];
cursor: pointer;
border-radius: 12px;
}
.unsloth-plus-menu [data-slot="dropdown-menu-label"] {
@apply pl-4 pr-3 py-1.5 text-[12px];
}
/* Dark hover: the accent nearly matches the surface, so use a clear overlay. */
.dark
.unsloth-plus-menu
:is(
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-sub-trigger"]
):is(
:hover,
:focus,
:focus-visible,
[data-highlighted],
[data-state="open"]
) {
background-color: rgba(255, 255, 255, 0.08) !important;
}
.unsloth-plus-menu
:is(
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-sub-trigger"]
)
svg {
width: 1.05rem;
height: 1.05rem;
}
/* Thinking menu: tighter gap; nowrap keeps "Preserve thinking" on one line
so the menu sizes to its content. */
.unsloth-thinking-menu
:is(
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-sub-trigger"]
) {
@apply gap-1.5 pl-2.5 pr-2.5;
white-space: nowrap;
}
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */ /* Fine-tuning Studio: equal default height, expandable when needed (md+) */
.min-h-studio-config-column { .min-h-studio-config-column {
@apply md:min-h-[470px]; @apply md:min-h-[470px];
@ -915,9 +1345,24 @@
.aui-assistant-message-content > *:last-child, .aui-assistant-message-content > *:last-child,
.aui-assistant-message-content > *:last-child > *:last-child, .aui-assistant-message-content > *:last-child > *:last-child,
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child, .aui-assistant-message-content > *:last-child > *:last-child > *:last-child,
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child, .aui-assistant-message-content
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child, > *:last-child
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child { > *:last-child
> *:last-child
> *:last-child,
.aui-assistant-message-content
> *:last-child
> *:last-child
> *:last-child
> *:last-child
> *:last-child,
.aui-assistant-message-content
> *:last-child
> *:last-child
> *:last-child
> *:last-child
> *:last-child
> *:last-child {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
@ -945,13 +1390,19 @@
font-family: "Fira Code", 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 { .aui-thread-root
:where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6)
code {
font-family: "Fira Code", ui-monospace, monospace; font-family: "Fira Code", ui-monospace, monospace;
} }
/* Align fenced code blocks with the main chat column even when nested in lists. */ /* Align fenced code blocks with the main chat column even when nested in lists. */
.aui-thread-root [data-streamdown="list-item"] > [data-streamdown="code-block"], .aui-thread-root
.aui-thread-root [data-streamdown="list-item"] [data-streamdown="code-block"] { [data-streamdown="list-item"]
> [data-streamdown="code-block"],
.aui-thread-root
[data-streamdown="list-item"]
[data-streamdown="code-block"] {
margin-left: -1.25rem; margin-left: -1.25rem;
width: calc(100% + 1.25rem); width: calc(100% + 1.25rem);
max-width: calc(100% + 1.25rem); max-width: calc(100% + 1.25rem);
@ -996,15 +1447,19 @@
} }
} }
/* Lighter shadow + tighter vertical padding than Sonner's defaults; !important because Sonner injects its base rules at runtime. */ /* No border line; same drop shadow as the composer (.unsloth-composer-surface).
[data-sonner-toast][data-styled='true'] { !important because Sonner injects its base rules at runtime. */
[data-sonner-toast][data-styled="true"] {
padding: 10px 16px !important; padding: 10px 16px !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08) !important; box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16) !important;
/* Pin to the light --radius so dark mode (smaller --radius) matches. */
border-radius: 1.1rem !important;
} }
/* Boost shadow on dark surfaces; mirrors .shadow-border / .menu-soft-surface pattern. */ /* Match the composer (.unsloth-composer-surface) in dark mode: same surface color, no shadow. */
.dark [data-sonner-toast][data-styled='true'] { .dark [data-sonner-toast][data-styled="true"] {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important; background-color: #2a2a2c !important;
box-shadow: none !important;
} }
/* Selectable toast text; non-selectable toast buttons. */ /* Selectable toast text; non-selectable toast buttons. */
@ -1135,15 +1590,15 @@
} }
[data-sidebar="content"] { [data-sidebar="content"] {
scrollbar-color: oklch(0.5 0 0 / 0.22) var(--sidebar); scrollbar-color: oklch(0.5 0 0 / 0.22) transparent;
} }
.dark [data-sidebar="content"] { .dark [data-sidebar="content"] {
scrollbar-color: oklch(0.72 0 0 / 0.25) var(--sidebar); scrollbar-color: oklch(0.72 0 0 / 0.25) transparent;
} }
[data-sidebar="content"]::-webkit-scrollbar-track { [data-sidebar="content"]::-webkit-scrollbar-track {
background: var(--sidebar); background: transparent;
} }
[data-sidebar="content"]::-webkit-scrollbar-thumb { [data-sidebar="content"]::-webkit-scrollbar-thumb {
@ -1177,15 +1632,21 @@
top: 8px !important; top: 8px !important;
background: var(--popover) !important; background: var(--popover) !important;
color: var(--popover-foreground) !important; color: var(--popover-foreground) !important;
border-color: var(--border) !important; border-color: transparent !important;
} }
/* Keep the (borderless) close button blended with the dark toast surface. */
.dark [data-sonner-toast][data-styled="true"] [data-close-button] {
background: #2a2a2c !important;
}
[data-sonner-toast][data-styled="true"] [data-close-button] svg { [data-sonner-toast][data-styled="true"] [data-close-button] svg {
stroke-width: 2.25; stroke-width: 2.25;
} }
[data-sonner-toast][data-styled="true"]:hover [data-close-button]:hover { [data-sonner-toast][data-styled="true"]:hover [data-close-button]:hover {
background: var(--muted) !important; background: var(--muted) !important;
color: var(--popover-foreground) !important; color: var(--popover-foreground) !important;
border-color: var(--border) !important; border-color: transparent !important;
} }
.generated-image-loading-card { .generated-image-loading-card {