Studio: refine tool call and reasoning trigger UI (#5873)

* Studio: refine tool call and reasoning trigger UI

Tool call triggers:
- Chevron fades in on hover or keyboard focus and sits next to the
  label instead of being pinned to the right edge, matching the other
  collapsible triggers.
- Labels wrap instead of truncating so long tool names and search
  queries stay fully readable.
- Smaller chevron for a lighter look.

Reasoning trigger:
- Smaller chevron to match.
- Thinking box drops its bottom padding and raises the streaming max
  height so more of the thinking text is visible.

* Studio: pointer cursors and sidebar 3-dots polish

Collapsible triggers:
- Pointer cursor on the reasoning, tool call, and tool group triggers
  so they read as clickable.

Chat sidebar:
- Swap the chat row 3-dots menu to the vertical more-vertical icon.
- Pointer cursor on the chat row and its menu button.
- Chat row right padding opens up on hover (pr-4 at rest, pr-8 on
  hover) so the title keeps a comfortable gap and clears the menu.

* studio: refine tool-call spinner, chevron, and reasoning spacing

- Use the lucide arc spinner for running tool calls and the app-wide
  Spinner, so loading states match the rest of the UI.
- Collapse long tool-call labels to a single line with an ellipsis,
  reveal the full label when the row is expanded, and fix the clipped
  descenders.
- Keep the collapse chevron next to the label and add top spacing above
  the reasoning trigger.
- Remove the redundant nested spinner in the web search running state.

* Studio: drop tool call group background fill

The ghost tool call group used a translucent bg-muted/10 fill that read
as a faint lighter box around every group in dark mode. Remove the fill
and rounding so the group sits flush on the chat background.

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
Michael Han 2026-06-05 01:55:03 -07:00 committed by GitHub
commit 9f1d029c18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 89 additions and 41 deletions

View file

@ -33,7 +33,7 @@ import {
const ANIMATION_DURATION = 200;
const AUTO_SCROLL_THRESHOLD_PX = 24;
export const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", {
export const reasoningVariants = cva("aui-reasoning-root mt-3 mb-4 w-full", {
variants: {
variant: {
outline: "rounded-lg border px-3 py-2",
@ -121,7 +121,7 @@ function ReasoningTrigger({
<CollapsibleTrigger
data-slot="reasoning-trigger"
className={cn(
"aui-reasoning-trigger group/trigger flex min-w-0 flex-1 items-center gap-2 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground",
"aui-reasoning-trigger group/trigger flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
@ -140,7 +140,7 @@ function ReasoningTrigger({
<ChevronDownIcon
data-slot="reasoning-trigger-chevron"
className={cn(
"aui-reasoning-trigger-chevron mt-0.5 size-4 shrink-0",
"aui-reasoning-trigger-chevron mt-0.5 size-3.5 shrink-0",
"transition-transform duration-(--animation-duration) ease-out",
"group-data-[state=closed]/trigger:-rotate-90",
"group-data-[state=open]/trigger:rotate-0",
@ -241,8 +241,8 @@ function ReasoningText({
ref={scrollRef}
data-slot="reasoning-text"
className={cn(
"aui-reasoning-text relative z-0 overflow-y-auto pt-2 pb-2 pl-0 leading-relaxed",
streaming ? "max-h-32" : "max-h-64",
"aui-reasoning-text relative z-0 overflow-y-auto pt-2 pb-0 pl-0 leading-relaxed",
streaming ? "max-h-64" : "",
"transform-gpu transition-[transform,opacity]",
"group-data-[state=open]/collapsible-content:animate-in",
"group-data-[state=closed]/collapsible-content:animate-out",

View file

@ -0,0 +1,21 @@
// 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 is running: a clean circular arc with a
* rounded cap (lucide Loader2 / LoaderCircle), animated. Inherits the current
* text color so it matches the surrounding font.
*/
export function ToolCallSpinner({ className }: { className?: string }) {
return (
<Loader2Icon
aria-label="Loading"
className={cn("size-4 shrink-0 animate-spin", className)}
/>
);
}

View file

@ -8,6 +8,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ToolCallSpinner } from "@/components/assistant-ui/tool-call-spinner";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
import {
@ -135,16 +136,13 @@ function ToolFallbackTrigger({
<CollapsibleTrigger
data-slot="tool-fallback-trigger"
className={cn(
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 py-1.5 text-sm transition-colors",
"aui-tool-fallback-trigger group/trigger flex w-full cursor-pointer items-center gap-2 py-1.5 text-sm transition-colors",
className,
)}
{...props}
>
{isRunning ? (
<StatusIcon
data-slot="tool-fallback-trigger-icon"
className="aui-tool-fallback-trigger-icon size-4 shrink-0 animate-spin"
/>
<ToolCallSpinner className="aui-tool-fallback-trigger-icon" />
) : ToolIcon ? (
<ToolIcon
data-slot="tool-fallback-trigger-icon"
@ -165,11 +163,16 @@ function ToolFallbackTrigger({
<span
data-slot="tool-fallback-trigger-label"
className={cn(
"aui-tool-fallback-trigger-label-wrapper relative min-w-0 grow text-left leading-none text-muted-foreground",
"aui-tool-fallback-trigger-label-wrapper relative min-w-0 text-left leading-none text-muted-foreground",
isCancelled && "text-muted-foreground line-through",
)}
>
<span className="block truncate">
<span
className={cn(
"block truncate leading-normal",
"group-data-[state=open]/trigger:overflow-visible group-data-[state=open]/trigger:whitespace-normal group-data-[state=open]/trigger:break-words",
)}
>
{label}:{" "}
<span className="font-medium text-foreground/85">{displayName}</span>
</span>
@ -177,7 +180,10 @@ function ToolFallbackTrigger({
<span
aria-hidden={true}
data-slot="tool-fallback-trigger-shimmer"
className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 block truncate motion-reduce:animate-none"
className={cn(
"aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 block truncate leading-normal motion-reduce:animate-none",
"group-data-[state=open]/trigger:overflow-visible group-data-[state=open]/trigger:whitespace-normal group-data-[state=open]/trigger:break-words",
)}
>
{label}:{" "}
<span className="font-medium text-foreground/85">{displayName}</span>
@ -187,8 +193,8 @@ function ToolFallbackTrigger({
<ChevronDownIcon
data-slot="tool-fallback-trigger-chevron"
className={cn(
"aui-tool-fallback-trigger-chevron size-4 shrink-0",
"transition-transform duration-(--animation-duration) ease-out",
"aui-tool-fallback-trigger-chevron mr-1 size-3.5 shrink-0 self-center",
"transition-[transform,opacity] duration-(--animation-duration) ease-out",
"group-data-[state=closed]/trigger:-rotate-90",
"group-data-[state=open]/trigger:rotate-0",
)}

View file

@ -9,7 +9,7 @@ import {
type PropsWithChildren,
} from "react";
import { useAuiState } from "@assistant-ui/react";
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
import { ChevronDownIcon } from "lucide-react";
import { Wrench01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cva, type VariantProps } from "class-variance-authority";
@ -20,6 +20,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";
const ANIMATION_DURATION = 200;
@ -27,7 +28,7 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
variants: {
variant: {
outline: "corner-squircle rounded-lg border py-3",
ghost: "rounded-lg bg-muted/10 py-2",
ghost: "py-2",
muted:
"corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
},
@ -113,7 +114,7 @@ function ToolGroupTrigger({
<CollapsibleTrigger
data-slot="tool-group-trigger"
className={cn(
"aui-tool-group-trigger group/trigger flex w-full items-center gap-2 text-sm transition-colors",
"aui-tool-group-trigger group/trigger flex w-full cursor-pointer items-center gap-2 text-sm transition-colors",
"group-data-[variant=outline]/tool-group-root:px-4",
"group-data-[variant=muted]/tool-group-root:px-4",
"group-data-[variant=ghost]/tool-group-root:px-0",
@ -122,10 +123,7 @@ function ToolGroupTrigger({
{...props}
>
{active ? (
<LoaderIcon
data-slot="tool-group-trigger-loader"
className="aui-tool-group-trigger-loader size-4 shrink-0 animate-spin"
/>
<ToolCallSpinner className="aui-tool-group-trigger-loader" />
) : (
<HugeiconsIcon
icon={Wrench01Icon}
@ -137,7 +135,7 @@ function ToolGroupTrigger({
<span
data-slot="tool-group-trigger-label"
className={cn(
"aui-tool-group-trigger-label-wrapper relative inline-block grow text-left font-medium leading-none",
"aui-tool-group-trigger-label-wrapper relative inline-block text-left font-medium leading-none",
)}
>
<span>{label}</span>
@ -154,7 +152,7 @@ function ToolGroupTrigger({
<ChevronDownIcon
data-slot="tool-group-trigger-chevron"
className={cn(
"aui-tool-group-trigger-chevron size-4 shrink-0",
"aui-tool-group-trigger-chevron size-3.5 shrink-0",
"transition-transform duration-(--animation-duration) ease-out",
"group-data-[state=closed]/trigger:-rotate-90",
"group-data-[state=open]/trigger:rotate-0",

View file

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

View file

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

View file

@ -5,7 +5,8 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, CopyIcon, LoaderIcon, TerminalIcon } from "lucide-react";
import { CheckIcon, CopyIcon, TerminalIcon } from "lucide-react";
import { ToolCallSpinner } from "@/components/assistant-ui/tool-call-spinner";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import {
ToolFallbackContent,
@ -86,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">
<LoaderIcon className="size-3.5 animate-spin" />
<ToolCallSpinner className="size-3.5" />
<span>Running&hellip;</span>
</div>
) : output ? (

View file

@ -4,7 +4,7 @@
"use client";
import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
import { GlobeIcon, LoaderIcon } from "lucide-react";
import { GlobeIcon } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { Source, SourceIcon, SourceTitle } from "./sources";
import {
@ -116,8 +116,7 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
/>
<ToolFallbackContent>
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<div className="flex items-center text-sm text-muted-foreground">
<span>
{isUrlFetch
? <>Reading {displayDomain || "page"}&hellip;</>

View file

@ -1,19 +1,23 @@
// 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 { cn } from "@/lib/utils"
"use client";
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* App-wide spinner: a clean circular arc with a rounded cap (lucide
* Loader2 / LoaderCircle), animated, inheriting the current text color.
*/
function Spinner({ className }: { className?: string }) {
return (
<span
<Loader2Icon
role="status"
aria-label="Loading"
className={cn(
"inline-block size-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent",
className,
)}
className={cn("size-4 shrink-0 animate-spin", className)}
/>
)
);
}
export { Spinner }
export { Spinner };

View file

@ -2012,3 +2012,21 @@
transition-duration: 250ms !important;
}
}
/*
* Tool-call chevron visibility:
* - A solo tool call (not wrapped in a multi-tool group) always shows its
* collapsible chevron.
* - When the tool call is part of a multi-tool group (.aui-tool-group-root),
* the chevron stays hidden and only reveals on hover / keyboard focus.
*/
.aui-tool-fallback-trigger-chevron {
opacity: 1;
}
.aui-tool-group-root .aui-tool-fallback-trigger-chevron {
opacity: 0;
}
.aui-tool-group-root .aui-tool-fallback-trigger:hover .aui-tool-fallback-trigger-chevron,
.aui-tool-group-root .aui-tool-fallback-trigger:focus-visible .aui-tool-fallback-trigger-chevron {
opacity: 1;
}