unsloth/studio/frontend/src/components/app-sidebar.tsx
Daniel Han-Chen b2b660f76f Studio: add local diffusion image generation page
Backend
- core/inference/diffusion.py: DiffusionBackend singleton that loads
  diffusion GGUFs from Hugging Face via diffusers.GGUFQuantizationConfig
  and runs them on the active CUDA / MPS / CPU device. Supports FLUX.2,
  FLUX.2 klein, FLUX.1, Qwen-Image, Stable Diffusion 3, and SDXL.
- routes/inference.py: POST /api/inference/images/load,
  POST /api/inference/images/generate, POST /api/inference/images/unload,
  GET /api/inference/images/status mirroring the llama-server lifecycle.
- models/inference.py: DiffusionLoadRequest, DiffusionGenerateRequest,
  DiffusionGenerateResponse pydantic schemas with prompt / step / size
  validation up front so callers get clear 422s rather than VAE crashes.
- requirements/no-torch-runtime.txt: pin gguf alongside the existing
  diffusers entry so GGUFQuantizationConfig works out of the box.
- tests/test_diffusion_backend.py + tests/test_diffusion_routes.py:
  27 unit tests covering family detection, validation, lifecycle, and
  the full FastAPI round trip with the backend stubbed. No torch /
  diffusers / GPU required to run.

Frontend
- features/images/: standalone images-page.tsx with curated model picker
  (FLUX.2 klein 4B / 9B, FLUX.2 dev, FLUX.1 dev), HF token field,
  prompt + negative prompt, resolution presets, steps + guidance
  sliders, seed input, and a result gallery that renders base64 PNGs
  inline.
- app/routes/images.tsx: lazy /images route wired into router.tsx.
- components/app-sidebar.tsx: PaintBrush02Icon nav item between
  Recipes and Export, hidden in chat-only mode.
2026-05-24 14:26:07 +00:00

898 lines
35 KiB
TypeScript

// 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 {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils";
import {
ChefHatIcon,
ColumnInsertIcon,
CursorInfo02Icon,
Delete02Icon,
DownloadSquare01Icon,
Edit03Icon,
Globe02Icon,
HelpCircleIcon,
Logout01Icon,
PaintBrush02Icon,
Search01Icon,
PowerIcon,
PencilEdit02Icon,
LayoutAlignLeftIcon,
Settings02Icon,
TestTube01Icon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import {
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "lucide-react";
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import {
ChatSearchDialog,
deleteChatItem,
renameChatItem,
useChatRuntimeStore,
useChatSearchStore,
useChatSidebarItems,
type SidebarItem,
} from "@/features/chat";
import { useSettingsDialogStore } from "@/features/settings";
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
import { usePlatformStore } from "@/config/env";
import { clearAuthTokens, logout } from "@/features/auth";
import { TOUR_OPEN_EVENT } from "@/features/tour";
import {
deleteTrainingRun,
emitTrainingRunDeleted,
emitTrainingRunUpdated,
removeTrainingUnloadGuard,
renameTrainingRun,
useTrainingHistorySidebarItems,
useTrainingRuntimeStore,
} from "@/features/training";
import type { TrainingRunSummary } from "@/features/training";
import { useEffect, useRef, useState } from "react";
import { toast } from "@/lib/toast";
import { ShutdownDialog } from "@/components/shutdown-dialog";
function getTourId(pathname: string): string | null {
if (pathname.startsWith("/studio")) return "studio";
if (pathname.startsWith("/export")) return "export";
if (pathname.startsWith("/chat")) return "chat";
return null;
}
// Hugeicons' TestTube01Icon ships with two interior bubbles (paths #4
// and #5 of the 5-path definition). Slicing to the first three paths
// keeps the test-tube outline + horizontal cap + liquid line, dropping
// the bubbles. The original export stays untouched, and HugeiconsIcon
// renders this trimmed array exactly the same way.
const TestTubeOutlineIcon = TestTube01Icon.slice(
0,
3,
) as typeof TestTube01Icon;
function runStatusDotClass(status: TrainingRunSummary["status"]): string {
switch (status) {
case "running":
return "bg-blue-500 animate-pulse";
case "completed":
return "bg-emerald-500";
case "stopped":
return "bg-amber-500";
case "error":
return "bg-red-500";
default:
return "bg-muted-foreground";
}
}
function formatRelativeShort(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const diffMs = Date.now() - then;
const s = Math.max(0, Math.floor(diffMs / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
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,
active,
disabled,
onClick,
children,
dataTour,
}: {
icon: typeof ZapIcon;
label: string;
active: boolean;
disabled?: boolean;
onClick: () => void;
children?: React.ReactNode;
dataTour?: string;
}) {
return (
<SidebarMenuItem>
<div className="relative">
<SidebarMenuButton
tooltip={label}
disabled={disabled}
onClick={onClick}
isActive={active}
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"
>
<HugeiconsIcon 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>
</div>
{children}
</SidebarMenuItem>
);
}
export function AppSidebar() {
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
const { pathname, search } = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
search: s.location.search as Record<string, string | undefined>,
}),
});
const { togglePinned, isMobile, setOpenMobile } = useSidebar();
const navigate = useNavigate();
// Auto-close mobile Sheet after navigation
const closeMobileIfOpen = () => {
if (isMobile) setOpenMobile(false);
};
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
const chatOnly = usePlatformStore((s) => s.isChatOnly());
const [shutdownOpen, setShutdownOpen] = useState(false);
// 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);
useEffect(() => { if (isChatRoute) setChatOpen(true); }, [isChatRoute]);
useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]);
const scrollRef = useRef<HTMLDivElement | null>(null);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const handler = () => setScrolled(el.scrollTop > 0);
handler();
el.addEventListener("scroll", handler, { passive: true });
return () => el.removeEventListener("scroll", handler);
}, []);
const isRecipesRoute = pathname.startsWith("/data-recipes");
const { displayTitle, avatarDataUrl } = useEffectiveProfile();
const { items: chatItems } = useChatSidebarItems();
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId);
const activeThreadId = isChatRoute
? (search.thread as string | undefined) ??
(search.compare as string | undefined) ??
storeThreadId ??
undefined
: undefined;
// Training runs
const { items: runItems } = useTrainingHistorySidebarItems(
!chatOnly && isStudioRoute,
);
const activeJobId = useTrainingRuntimeStore((s) => s.jobId);
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
const chatDisabled = isTrainingRunning;
async function handleDeleteThread(item: Parameters<typeof deleteChatItem>[0]) {
await deleteChatItem(item, activeThreadId, (view) => {
navigate({
to: "/chat",
search: { new: view.newThreadNonce },
});
});
}
type RenameTarget =
| { kind: "chat"; item: SidebarItem; current: string }
| { kind: "run"; run: TrainingRunSummary; current: string };
const [renamingTarget, setRenamingTarget] = useState<RenameTarget | null>(
null,
);
const [renameDraft, setRenameDraft] = useState("");
const renameTrimmed = renameDraft.trim();
const nextRunDisplayName = renameTrimmed.length > 0 ? renameTrimmed : null;
const renameDirty =
renamingTarget !== null &&
(renamingTarget.kind === "chat"
? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current
: renameTrimmed.length > 0
? renameTrimmed !== renamingTarget.current
: renamingTarget.run.display_name != null);
function openRenameChat(item: SidebarItem) {
setRenameDraft(item.title);
setRenamingTarget({ kind: "chat", item, current: item.title });
}
function openRenameRun(run: TrainingRunSummary) {
const current = run.display_name ?? run.model_name;
setRenameDraft(current);
setRenamingTarget({ kind: "run", run, current });
}
async function commitRename() {
const target = renamingTarget;
if (!target || !renameDirty) return;
setRenamingTarget(null);
if (target.kind === "chat") {
try {
await renameChatItem(target.item, renameTrimmed);
} catch (err) {
toast.error("Failed to rename chat", {
description: err instanceof Error ? err.message : undefined,
});
}
return;
}
try {
const updated = await renameTrainingRun(target.run.id, nextRunDisplayName);
emitTrainingRunUpdated(updated);
} catch (err) {
toast.error("Failed to rename run", {
description: err instanceof Error ? err.message : undefined,
});
}
}
type DeleteTarget =
| { kind: "chat"; item: SidebarItem }
| { kind: "run"; run: TrainingRunSummary };
const [confirmingDelete, setConfirmingDelete] =
useState<DeleteTarget | null>(null);
async function commitDelete() {
const target = confirmingDelete;
if (!target) return;
setConfirmingDelete(null);
if (target.kind === "chat") {
try {
await handleDeleteThread(target.item);
} catch (err) {
toast.error("Failed to delete chat", {
description: err instanceof Error ? err.message : undefined,
});
}
return;
}
if (target.run.status === "running") {
toast.error("Cannot delete a running training run");
return;
}
try {
await deleteTrainingRun(target.run.id);
if (selectedHistoryRunId === target.run.id) {
setSelectedHistoryRunId(null);
}
emitTrainingRunDeleted(target.run.id);
} catch (err) {
toast.error("Failed to delete run", {
description: err instanceof Error ? err.message : undefined,
});
}
}
return (
<>
<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-[8px] group-data-[collapsible=icon]:px-0">
{/* Expanded: compact logo + close toggle */}
<div className="flex items-center justify-between gap-[8.5px] group-data-[collapsible=icon]:hidden">
<Link
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="/circle-logo-small.png"
alt="Unsloth"
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 className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
BETA
</span>
</Link>
{!isMobile && (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
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="Close sidebar"
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="tooltip-compact"
>
Close sidebar
</TooltipContent>
</Tooltip>
)}
</div>
{/* Collapsed: panel icon doubles as expand trigger */}
{!isMobile && (
<div className="hidden group-data-[collapsible=icon]:flex h-[35px] items-center justify-center w-full">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
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="Open sidebar"
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent
side="right"
sideOffset={8}
className="tooltip-compact"
>
Open sidebar
</TooltipContent>
</Tooltip>
</div>
)}
</SidebarHeader>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[8px] shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={PencilEdit02Icon}
label="New Chat"
active={false}
disabled={chatDisabled}
onClick={() => {
if (chatDisabled) return;
setActiveThreadId(null);
navigate({ to: "/chat", search: { new: createNavigationNonce() } });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ColumnInsertIcon}
label="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();
}}
/>
<NavItem
icon={Search01Icon}
label="Search"
active={false}
disabled={chatDisabled}
onClick={() => {
if (chatDisabled) return;
useChatSearchStore.getState().open();
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[20px] shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={TestTubeOutlineIcon}
label="Train"
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ChefHatIcon}
label="Recipes"
active={isRecipesRoute}
onClick={() => {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={PaintBrush02Icon}
label="Images"
active={pathname === "/images" || pathname.startsWith("/images/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/images" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={DownloadSquare01Icon}
label="Export"
active={pathname === "/export" || pathname.startsWith("/export/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/export" });
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarContent ref={scrollRef} className="gap-0 overflow-y-auto overscroll-contain min-h-0">
{/* 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 px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recents
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="px-2">
<SidebarMenu>
{chatItems.map((item) => (
<SidebarMenuItem key={item.id} className="group/recent-item relative">
<SidebarMenuButton
data-testid="recent-thread"
data-thread-type={item.type}
data-thread-id={item.id}
isActive={activeThreadId === item.id}
className="sidebar-nav-btn h-[32px] rounded-[10px] pl-2.5 pr-2.5 group-hover/recent-item:pr-10 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-10 text-[14.5px] leading-[19px] tracking-nav font-medium"
onClick={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id }
: { compare: item.id },
});
closeMobileIfOpen();
}}
>
<span className="truncate">{item.title}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Chat options"
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">
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={4}
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)}>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
{/* Recent Runs */}
{isStudioRoute && runItems.length > 0 && !chatOnly && (
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recents
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="px-2">
<SidebarMenu>
{runItems.map((run) => {
const isActiveRun =
selectedHistoryRunId === run.id || activeJobId === run.id;
return (
<SidebarMenuItem
key={run.id}
className="group/run-item relative"
>
<SidebarMenuButton
isActive={isActiveRun}
className="sidebar-nav-btn h-auto flex-col items-start gap-0.5 py-[5px] rounded-[10px] pl-2.5 pr-7 text-[14.5px] tracking-nav font-medium"
onClick={() => {
setSelectedHistoryRunId(run.id);
closeMobileIfOpen();
}}
>
<div className="flex w-full items-center gap-[8.5px]">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
runStatusDotClass(run.status),
)}
aria-hidden
/>
<span className="truncate">
{run.display_name ?? run.model_name}
</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
{formatRelativeShort(run.started_at)}
</span>
</div>
<span className="w-full truncate pl-3.5 text-xs text-muted-foreground">
{run.dataset_name}
</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Run options"
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">
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={4}
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)}>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={run.status === "running"}
onSelect={() =>
setConfirmingDelete({ kind: "run", run })
}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-transparent group-data-[collapsible=icon]:px-0">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
aria-label={`${displayTitle} account menu`}
className="sidebar-nav-btn !h-[50px] gap-[8px] px-2 py-[9px] rounded-[10px]"
>
<div className="shrink-0">
<UserAvatar
name={displayTitle}
imageUrl={avatarDataUrl}
size="sm"
className="!size-8"
/>
</div>
<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 text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
</div>
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="app-user-menu menu-soft-surface-up ring-0 w-[15rem] py-2.5 font-heading rounded-[14px] border-0"
>
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog()}
>
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-icon" />
<span>Settings</span>
<DropdownMenuShortcut>,</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog("api-keys")}
>
<HugeiconsIcon icon={Globe02Icon} strokeWidth={1.75} className="size-[18px]" />
<span>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">
New
</span>
</DropdownMenuItem>
<DropdownMenuItem
ref={anchorRef as React.Ref<HTMLDivElement>}
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
>
{isDark ? <Sun strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />}
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!getTourId(pathname)}
onSelect={() => {
const tourId = getTourId(pathname);
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, {
detail: { id: tourId },
}),
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
<span>Guided Tour</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog("about")}
>
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" />
<span>Help</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
// Best-effort server-side revocation; ignore network errors
// so the local clear path still runs and the user lands on /login.
try {
await logout();
} catch {
clearAuthTokens();
}
void navigate({ to: "/login" });
}}
>
<HugeiconsIcon icon={Logout01Icon} strokeWidth={1.75} className="size-icon" />
<span>Log out</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
<span>Shutdown</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<ChatSearchDialog />
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
<Dialog
open={confirmingDelete !== null}
onOpenChange={(open) => {
if (!open) setConfirmingDelete(null);
}}
>
<DialogContent className="menu-flat-destructive corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
<DialogHeader>
<DialogTitle>
{confirmingDelete?.kind === "run"
? "Delete training run"
: "Delete chat"}
</DialogTitle>
<DialogDescription>
{confirmingDelete?.kind === "run" ? (
<>
Are you sure you want to delete this run{" "}
<em>{confirmingDelete.run.display_name ?? confirmingDelete.run.model_name}</em>?
</>
) : confirmingDelete?.kind === "chat" ? (
<>
Are you sure you want to delete this chat{" "}
<em>{confirmingDelete.item.title}</em>?
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setConfirmingDelete(null)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void commitDelete()}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={renamingTarget !== null}
onOpenChange={(open) => {
if (!open) setRenamingTarget(null);
}}
>
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
<DialogHeader>
<DialogTitle>
{renamingTarget?.kind === "run" ? "Rename run" : "Rename chat"}
</DialogTitle>
</DialogHeader>
<Input
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void commitRename();
}
}}
autoFocus
maxLength={120}
placeholder={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
aria-label={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
className="focus-visible:border-input focus-visible:ring-0"
/>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setRenamingTarget(null)}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void commitRename()}
disabled={!renameDirty}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}