Studio: loading spinner consolidation and sidebar hover pill polish (#6334)

* Studio: use the shared spinner for loading toasts

The loading toast used a different spinner from the model download toast,
so 'Unloading model' and 'Downloading model' looked inconsistent. Switch
the sonner loading icon to the app-wide Spinner so they match.

* Studio: consolidate loading spinners onto the shared Spinner

Replace the duplicate ToolCallSpinner and the remaining inline loading
spinners (hugeicons Loading03, lucide Loader2/Loader/LoaderCircle, and
the hand-rolled border rings) with the shared Spinner component, then
delete tool-call-spinner.tsx. Refresh and regenerate action icons and
the shimmer button are left alone since they are not loading spinners.

* Studio: align and even out sidebar hover pills

The Recents chat rows started further left than the nav items above them
and the hover pill widths varied. Give every nav and chat group the same
pl-1.5 pr-2 so the pills line up and share one width, and on hover the
chat rows only reserve room for the kebab so the title keeps one more
character.

* Studio: address review feedback on spinner and sidebar polish

- recipe-graph-node: restore rounded-full bg-background on the spinner
  wrapper so it keeps masking the grid lines behind the floating spinner
  (the original hand-rolled ring had this mask).
- app-sidebar: revert the pinned-row open-state padding to pr-8 so the
  unpin button stays clear of the title when the kebab menu is open.
- app-sidebar: correct the now-stale inset comment (pl-1.5 = 6px, 18px).
This commit is contained in:
Michael Han 2026-06-15 05:15:51 -07:00 committed by GitHub
commit 4ed40cb074
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 53 additions and 93 deletions

View file

@ -668,15 +668,17 @@ export function AppSidebar() {
: "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";
const buttonClass = cn(
"sidebar-nav-btn h-[33px] cursor-pointer rounded-full pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium",
// pl-3 (12px) plus the content's pl-1 (4px) lines the title up with the
// Recents label text at 16px.
// pl-3 (12px) over the content's pl-1.5 (6px) = 18px, aligning the
// title with the nav items above.
variant === "project" ? "pl-[39px]" : "pl-3",
variant === "project"
? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8"
? "group-hover/project-chat-item:pr-6 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-6"
: isPinned
? // Pinned rows show an extra unpin button on hover, so reserve more room.
? // Pinned rows show an extra unpin button on hover, so reserve more room
// (pr-8 when the menu is open keeps the unpin button clear of the title).
"group-hover/recent-item:pr-16 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8"
: "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8",
: // Hover room for the kebab only; title keeps one more character.
"group-hover/recent-item:pr-6 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-6",
);
const isRenamingThis =
@ -962,7 +964,8 @@ export function AppSidebar() {
)}
</SidebarHeader>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-1.5 pt-[9px] pb-px shrink-0">
{/* Uniform pl-1.5 pr-2 keeps every hover pill the same width, inset from the edge. */}
<SidebarGroup className="group-data-[collapsible=icon]:px-0 pl-1.5 pr-2 pt-[9px] pb-px shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
@ -1002,7 +1005,7 @@ export function AppSidebar() {
scrolled && "is-scrolled",
)}
>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-1.5 py-0 shrink-0">
<SidebarGroup className="group-data-[collapsible=icon]:px-0 pl-1.5 pr-2 py-0 shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
@ -1074,7 +1077,7 @@ export function AppSidebar() {
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="px-1.5">
<SidebarGroupContent className="pl-1.5 pr-2">
<SidebarMenu>
<NavItem
icon={TestTubeOutlineIcon}
@ -1124,7 +1127,7 @@ export function AppSidebar() {
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1 pr-1.5">
<SidebarGroupContent className="pl-1.5 pr-2">
<SidebarMenu>
{pinnedChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),
@ -1146,7 +1149,7 @@ export function AppSidebar() {
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1 pr-1.5">
<SidebarGroupContent className="pl-1.5 pr-2">
<SidebarMenu>
{recentChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),

View file

@ -17,10 +17,10 @@ import {
CopyIcon,
ImageIcon,
ImageOffIcon,
Loader2Icon,
RefreshCwIcon,
ShieldAlertIcon,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Download01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@ -348,7 +348,7 @@ function ImageGenerating({ className }: { className?: string }) {
className,
)}
>
<Loader2Icon className="size-8 animate-spin text-muted-foreground" />
<Spinner className="size-8 text-muted-foreground" />
<span className="sr-only">Generating image</span>
</div>
);

View file

@ -1,17 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
/** Spinner shown while a tool call runs. Inherits text color to match its surroundings. */
export function ToolCallSpinner({ className }: { className?: string }) {
return (
<Loader2Icon
aria-label="Loading"
className={cn("size-4 shrink-0 animate-spin", className)}
/>
);
}

View file

@ -8,7 +8,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ToolCallSpinner } from "@/components/assistant-ui/tool-call-spinner";
import { Spinner } from "@/components/ui/spinner";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
import {
@ -142,7 +142,7 @@ function ToolFallbackTrigger({
{...props}
>
{isRunning ? (
<ToolCallSpinner className="aui-tool-fallback-trigger-icon" />
<Spinner className="aui-tool-fallback-trigger-icon" />
) : ToolIcon ? (
<ToolIcon
data-slot="tool-fallback-trigger-icon"

View file

@ -21,7 +21,7 @@ import {
} from "@/components/ui/collapsible";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
import { ToolCallSpinner } from "@/components/assistant-ui/tool-call-spinner";
import { Spinner } from "@/components/ui/spinner";
const ANIMATION_DURATION = 200;
@ -124,7 +124,7 @@ function ToolGroupTrigger({
{...props}
>
{active ? (
<ToolCallSpinner className="aui-tool-group-trigger-loader" />
<Spinner className="aui-tool-group-trigger-loader" />
) : (
<HugeiconsIcon
icon={Wrench01Icon}

View file

@ -14,7 +14,7 @@ import {
FileTextIcon,
TerminalIcon,
} from "lucide-react";
import { ToolCallSpinner } from "@/components/assistant-ui/tool-call-spinner";
import { Spinner } from "@/components/ui/spinner";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ToolFallbackContent,
@ -181,7 +181,7 @@ const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
<ToolFallbackContent>
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ToolCallSpinner className="size-3.5" />
<Spinner className="size-3.5" />
<span>{runningLabel}</span>
</div>
) : resultText ? (

View file

@ -7,7 +7,8 @@ import {
type ToolCallMessagePartComponent,
useAuiState,
} from "@assistant-ui/react";
import { FileTextIcon, LibraryBigIcon, LoaderIcon } from "lucide-react";
import { FileTextIcon, LibraryBigIcon } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { memo, useEffect, useMemo, useState } from "react";
import { Badge } from "./badge";
import {
@ -106,7 +107,7 @@ const KnowledgeBaseToolUIImpl: ToolCallMessagePartComponent = ({
<ToolFallbackContent>
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<Spinner className="size-3.5" />
<span>
{query ? (
<>Searching documents for &ldquo;{query}&rdquo;&hellip;</>

View file

@ -8,7 +8,7 @@ import { getAuthToken } from "@/features/auth/session";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { code as codePlugin } from "@streamdown/code";
import { CheckIcon, CodeIcon, CopyIcon } from "lucide-react";
import { ToolCallSpinner } from "@/components/assistant-ui/tool-call-spinner";
import { Spinner } from "@/components/ui/spinner";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import {
@ -149,7 +149,7 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
{/* Output */}
{isRunning ? (
<div className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
<ToolCallSpinner className="size-3.5" />
<Spinner className="size-3.5" />
<span>Running&hellip;</span>
</div>
) : output ? (

View file

@ -6,7 +6,7 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, CopyIcon, TerminalIcon } from "lucide-react";
import { ToolCallSpinner } from "@/components/assistant-ui/tool-call-spinner";
import { Spinner } from "@/components/ui/spinner";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import {
ToolFallbackContent,
@ -87,7 +87,7 @@ const TerminalToolUIImpl: ToolCallMessagePartComponent = ({
<div className="border-l-2 border-muted-foreground/20 pl-2">
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ToolCallSpinner className="size-3.5" />
<Spinner className="size-3.5" />
<span>Running&hellip;</span>
</div>
) : output ? (

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { ShimmerButton } from "@/components/ui/shimmer-button";
import { Spinner } from "@/components/ui/spinner";
import type { BackendStatus } from "@/hooks/use-tauri-backend";
import type { CopySupportDiagnosticsResult } from "@/lib/tauri-diagnostics";
import { AnimatePresence, motion } from "motion/react";
@ -98,15 +99,6 @@ const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
// Sub-components
// ---------------------------------------------------------------------------
function TealSpinner({ size = 24 }: { size?: number }) {
return (
<span
className="inline-block animate-spin rounded-full border-2 border-primary border-t-transparent"
style={{ width: size, height: size, animationDuration: "0.8s" }}
/>
);
}
function Logo() {
return (
<div className="flex flex-col items-center gap-4">
@ -148,7 +140,7 @@ function CheckingContent() {
<Logo />
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<Spinner className="size-6 text-primary" />
<p className="text-sm text-muted-foreground">Checking...</p>
</div>
</div>
@ -194,7 +186,7 @@ function InstallingContent({
<Logo />
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<Spinner className="size-6 text-primary" />
<p className="text-sm font-bold text-foreground">Installing...</p>
<p className="text-sm font-bold text-muted-foreground">
Please wait a few mins, then you can start training.
@ -227,7 +219,7 @@ function RepairingContent({
<Logo />
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<Spinner className="size-6 text-primary" />
<p className="text-sm font-bold text-foreground">Updating existing Unsloth install...</p>
{latest && (
<p className="max-w-xs text-center text-xs text-muted-foreground">{latest}</p>
@ -325,7 +317,7 @@ function StartingContent() {
<Logo />
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<Spinner className="size-6 text-primary" />
<p className="text-sm text-muted-foreground">Starting server...</p>
</div>
</div>

View file

@ -4,6 +4,7 @@
import type { UpdateStatus } from "@/hooks/use-tauri-update";
import type { CopySupportDiagnosticsResult } from "@/lib/tauri-diagnostics";
import { AnimatePresence, motion } from "motion/react";
import { Spinner } from "@/components/ui/spinner";
import { useEffect, useRef, useState } from "react";
interface UpdateScreenProps {
@ -18,15 +19,6 @@ interface UpdateScreenProps {
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
function Spinner({ size = 24 }: { size?: number }) {
return (
<span
className="inline-block animate-spin rounded-full border-2 border-primary border-t-transparent"
style={{ width: size, height: size, animationDuration: "0.8s" }}
/>
);
}
function Logo() {
return (
<div className="flex flex-col items-center gap-4">
@ -135,7 +127,7 @@ export function UpdateScreen({
<Logo />
<div className="mt-8 flex flex-col items-center gap-2">
{!isError && <Spinner />}
{!isError && <Spinner className="size-6 text-primary" />}
<p className="text-sm font-semibold text-foreground">
{statusLabel(status)}
</p>

View file

@ -5,10 +5,10 @@ import {
Alert02Icon,
CheckmarkCircle02Icon,
InformationCircleIcon,
Loading03Icon,
MultiplicationSignCircleIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
@ -51,13 +51,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
className="size-4"
/>
),
loading: (
<HugeiconsIcon
icon={Loading03Icon}
strokeWidth={2}
className="size-4 animate-spin"
/>
),
// App-wide arc spinner so loading toasts match the "Downloading model" toast.
loading: <Spinner className="size-4 text-muted-foreground" />,
}}
style={
{

View file

@ -9,10 +9,10 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
FileTextIcon,
LoaderIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Sheet,
@ -209,7 +209,7 @@ function PdfPreview({
onLoadError={(e) => setError(e.message)}
loading={
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" /> Loading PDF
<Spinner className="size-3.5" /> Loading PDF
</div>
}
>
@ -444,7 +444,7 @@ export function DocumentPreviewSheet() {
<div className="min-h-0 flex-1">
{loading ? (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" /> Resolving source
<Spinner className="size-3.5" /> Resolving source
</div>
) : error ? (
<div className="p-6 text-sm text-muted-foreground">

View file

@ -1,7 +1,8 @@
// 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 { LoaderCircleIcon, XIcon } from "lucide-react";
import { XIcon } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { HugeiconsIcon } from "@hugeicons/react";
import { File02Icon } from "@hugeicons/core-free-icons";
import { Badge } from "@/components/assistant-ui/badge";
@ -40,11 +41,7 @@ export function DocumentStatusChip({
<span className="truncate">{filename}</span>
{/* spinner while indexing, else close button */}
{processing ? (
<LoaderCircleIcon
className="shrink-0 animate-spin size-3.5 text-muted-foreground"
role="status"
aria-label="Loading"
/>
<Spinner className="shrink-0 size-3.5 text-muted-foreground" />
) : onRemove ? (
<button
type="button"

View file

@ -4,6 +4,7 @@
import { MarkdownPreview } from "@/components/markdown/markdown-preview";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import {
BalanceScaleIcon,
@ -473,11 +474,9 @@ function RecipeGraphNodeBase({
)}
>
{runtimeState === "running" && config?.kind === "llm" && (
<div className="pointer-events-none absolute -top-7 right-2 z-20">
<span
className="block size-6 animate-spin rounded-full border-[3px] border-primary/90 border-t-transparent bg-background"
aria-label="Running"
/>
// rounded-full bg-background masks grid lines behind the floating spinner
<div className="pointer-events-none absolute -top-7 right-2 z-20 rounded-full bg-background">
<Spinner className="size-6 text-primary/90" />
</div>
)}
<NodeResizer

View file

@ -2,10 +2,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
CloudUploadIcon,
Cancel01Icon,
Loading03Icon,
CheckmarkCircle02Icon,
Alert02Icon,
} from "@hugeicons/core-free-icons";
import { Spinner } from "@/components/ui/spinner";
import { HugeiconsIcon } from "@hugeicons/react";
import { uploadUnstructuredFile, removeUnstructuredFile } from "../../api";
import {
@ -226,10 +226,7 @@ export function UnstructuredDropZone({
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm"
>
{entry.status === "uploading" && (
<HugeiconsIcon
icon={Loading03Icon}
className="text-muted-foreground size-4 animate-spin"
/>
<Spinner className="text-muted-foreground size-4" />
)}
{entry.status === "ok" && (
<HugeiconsIcon

View file

@ -14,7 +14,8 @@ import type { CheckFormatResponse } from "@/features/training/types/datasets";
import { cn } from "@/lib/utils";
import { AlertCircleIcon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Loader2, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
const CHATML_ROLES = ["system", "user", "assistant"] as const;
const ALPACA_ROLES = ["instruction", "input", "output"] as const;
@ -203,7 +204,7 @@ export function DatasetMappingCard({
>
{isAiLoading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Spinner className="mr-1.5 size-3.5" />
Analyzing dataset...
</>
) : (