Studio: quick eject from the model selector (#6654)

* Studio: quick eject from the model selector

Add a one-click eject shortcut to the loaded-model pill so users do not
have to open the picker to unload a model.

- The loaded-status indicator shows a green checkmark at rest and swaps to
  a red eject icon on pill hover, with an "Eject model" tooltip. Clicking
  it ejects without opening the picker.
- On Device tab now uses the placeholder "Search local models" instead of
  "Search Unsloth models".
- The picker's "Eject model" button uses medium font weight.

* Studio: drop unused group/eject marker class on the eject control

* Studio: make the inline eject control valid HTML

The eject shortcut was a focusable span (role/tabIndex) nested inside the
trigger button. A button's content model forbids focusable descendants, so
make it a plain decorative span (aria-hidden, no role/tabIndex) that keeps
the mouse shortcut. Keyboard and screen-reader users eject via the picker's
"Eject model" button.

* Studio: disable the inline eject shortcut on touch devices

On touch (no hover) the red eject icon and title tooltip never reveal, so
tapping the loaded pill could unload the model with no visible affordance.
Add [@media(hover:none)]:pointer-events-none so taps fall through to the
trigger and open the picker; touch users eject from the picker instead.

---------

Co-authored-by: shimmyshimmer <shimmyshimmer@users.noreply.github.com>
Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
This commit is contained in:
Michael Han 2026-06-30 07:52:03 -07:00 committed by GitHub
commit 0a3e5a3172
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 55 additions and 9 deletions

View file

@ -14,6 +14,7 @@ import { isCustomProviderType } from "@/features/chat/external-providers";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import {
CheckmarkCircle02Icon,
CloudIcon,
DashboardSquare01Icon,
Download01Icon,
@ -146,6 +147,7 @@ function ModelSelectorTrigger({
size = "default",
className,
dataTour,
onEject,
}: {
currentModel?: ModelOption;
isLoaded: boolean;
@ -154,6 +156,7 @@ function ModelSelectorTrigger({
size?: "sm" | "default" | "lg";
className?: string;
dataTour?: string;
onEject?: () => void;
}) {
return (
<PopoverTrigger asChild={true}>
@ -161,12 +164,15 @@ function ModelSelectorTrigger({
type="button"
data-tour={dataTour}
className={cn(
"unsloth-model-selector-trigger flex min-w-0 items-center gap-2 transition-colors",
"unsloth-model-selector-trigger group/trigger flex min-w-0 items-center gap-2 transition-colors",
// Suppress the pill's hover background while the eject hit area is
// hovered, so only the dot's own circle reacts.
variant === "outline" &&
"rounded-full border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
"rounded-full border border-border/60 hover:bg-[#ececec] has-[[data-eject-hit]:hover]:!bg-transparent dark:hover:bg-[#2d2e32]",
variant === "ghost" &&
"rounded-full hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
variant === "muted" && "rounded-full bg-muted hover:bg-muted/80",
"rounded-full hover:bg-[#ececec] has-[[data-eject-hit]:hover]:!bg-transparent dark:hover:bg-[#2d2e32]",
variant === "muted" &&
"rounded-full bg-muted hover:bg-muted/80 has-[[data-eject-hit]:hover]:!bg-muted",
// More left padding than right; the chevron is pulled close to the
// label (below) so the trigger reads balanced around the text.
size === "sm" && "h-8 pl-3 pr-1.5 text-xs",
@ -175,9 +181,44 @@ function ModelSelectorTrigger({
className,
)}
>
{isLoaded && (
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
)}
{isLoaded &&
(onEject ? (
// Loaded status doubles as a mouse eject shortcut: green checkmark
// at rest, red eject icon on pill hover, click to eject. A plain
// span (no role/tabIndex) keeps it out of the trigger button's
// content model, which forbids focusable descendants. Keyboard and
// screen-reader users eject via the picker's "Eject model" button.
// aria-hidden marks it decorative; stopPropagation stops the
// popover from toggling. On touch (no hover) the eject icon and
// tooltip never reveal, so pointer-events-none disables the
// shortcut there and taps open the picker instead of ejecting.
<span
aria-hidden={true}
title="Eject model"
data-eject-hit={true}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onEject();
}}
// Hit area larger than the icon, with a hover circle. Negative
// margin keeps the icon in the dot's original spot.
className="-m-1 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/10 dark:hover:bg-white/10 [@media(hover:none)]:pointer-events-none"
>
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
strokeWidth={1.75}
className="size-3.5 text-emerald-500 group-hover/trigger:hidden"
/>
<HugeiconsIcon
icon={RemoveCircleIcon}
strokeWidth={1.75}
className="hidden size-3.5 text-red-500 group-hover/trigger:block"
/>
</span>
) : (
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
))}
{currentModel?.icon ? (
<span className="flex shrink-0 items-center">
{currentModel.icon}
@ -644,6 +685,7 @@ export function ModelSelector({
size={size}
className={className}
dataTour={triggerDataTour}
onEject={onEject ? handleEject : undefined}
/>
<ModelSelectorContent
open={open}

View file

@ -2400,7 +2400,11 @@ export function HubModelPicker({
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search Unsloth models"
placeholder={
section === "downloaded"
? "Search local models"
: "Search Unsloth models"
}
data-model-picker-search-input={true}
className="field-soft h-9 border-0 pl-8 pr-8"
/>
@ -3447,7 +3451,7 @@ export function HubModelPicker({
<button
type="button"
onClick={onEject}
className="pointer-events-auto inline-flex items-center justify-center gap-2 rounded-md bg-popover px-3 py-2 text-[13px] text-destructive shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-[color-mix(in_srgb,var(--destructive)_12%,var(--popover))] dark:bg-[color-mix(in_srgb,var(--foreground)_10%,var(--sidebar))] dark:shadow-none dark:hover:bg-[color-mix(in_srgb,var(--destructive)_22%,var(--sidebar))]"
className="pointer-events-auto inline-flex items-center justify-center gap-2 rounded-md bg-popover px-3 py-2 text-[13px] font-medium text-destructive shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-[color-mix(in_srgb,var(--destructive)_12%,var(--popover))] dark:bg-[color-mix(in_srgb,var(--foreground)_10%,var(--sidebar))] dark:shadow-none dark:hover:bg-[color-mix(in_srgb,var(--destructive)_22%,var(--sidebar))]"
title="Eject model"
>
<HugeiconsIcon icon={RemoveCircleIcon} className="size-3.5" />