Studio: make the loaded models card resizable

The card is anchored bottom-right, where a native CSS resize grip has nowhere
to grow, so it resizes from a grip at the leading corner instead: the anchored
corner is held still and the box opens up and to the left. The grip shares the
title icon's slot rather than adding another control to a small header, and
double-clicking it returns the card to its default size and corner.

The size persists next to the position, is clamped to a floor and to the room
available, and is cleared by Reset all local preferences.
This commit is contained in:
shimmyshimmer 2026-08-07 20:10:39 -07:00
parent fb3ba5c3b5
commit 96749d1f0a
5 changed files with 289 additions and 33 deletions

View file

@ -19,6 +19,7 @@ import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import {
AiBrain01Icon,
ArrowExpandDiagonal01Icon,
Cancel01Icon,
DragDropVerticalIcon,
Image01Icon,
@ -144,12 +145,23 @@ export function LoadedModelsIndicator({
const enabled = showIndicator && canShowIndicator(pathname);
const { entries, ejecting, eject } = useLoadedModels(enabled);
const [collapsed, setCollapsed] = usePersistedToggle(COLLAPSED_KEY);
const { position, panelRef, startDrag, dragging, justDragged } =
useDragPosition(LOADED_MODELS_PREFERENCE_KEYS.position);
const {
position,
size,
panelRef,
startDrag,
startResize,
dragging,
resizing,
reset,
justDragged,
} = useDragPosition(LOADED_MODELS_PREFERENCE_KEYS);
if (!enabled || entries.length === 0) return null;
const countLabel = `${entries.length} ${entries.length === 1 ? "model" : "models"} loaded`;
// The pill keeps its own shape, so a resized card does not stretch it.
const sized = size && !collapsed ? size : null;
return (
<div
@ -159,14 +171,18 @@ export function LoadedModelsIndicator({
// Otherwise anchored bottom-right, or flowing as a right-aligned row
// in the shared stack so overlays stack instead of overlapping.
"pointer-events-none",
position && "fixed z-[9999] w-fit",
position && "fixed z-[9999]",
position && !sized && "w-fit",
!position &&
(positioned
? "fixed bottom-4 right-4 z-50"
: "flex min-h-0 justify-end"),
dragging && "select-none",
(dragging || resizing) && "select-none",
)}
style={position ? { left: position.left, top: position.top } : undefined}
style={{
...(position ? { left: position.left, top: position.top } : null),
...sized,
}}
>
{collapsed ? (
<Tooltip>
@ -197,13 +213,45 @@ export function LoadedModelsIndicator({
</TooltipContent>
</Tooltip>
) : (
<div className="menu-soft-surface pointer-events-auto flex min-h-0 w-[268px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[20px] p-1.5 font-heading">
<div
className={cn(
"menu-soft-surface group pointer-events-auto flex min-h-0 flex-col overflow-hidden rounded-[20px] p-1.5 font-heading",
// Sized: the wrapper already holds the clamped box, so filling it
// keeps the held corner exactly where the resize put it.
sized ? "size-full" : "w-[268px] max-w-[calc(100vw-2rem)]",
)}
>
<div className="flex items-center gap-1.5 px-1.5 pb-1 pt-0.5">
<HugeiconsIcon
icon={AiBrain01Icon}
strokeWidth={1.75}
className="size-[15px] shrink-0 text-muted-foreground"
/>
{/* The card is anchored bottom-right, where there is no room, so it
grows up and left and the grip belongs at the leading corner.
It shares the title icon's slot rather than adding another
control to an already small header. */}
<div className="relative flex size-[15px] shrink-0 items-center justify-center">
<HugeiconsIcon
icon={AiBrain01Icon}
strokeWidth={1.75}
className="size-[15px] text-muted-foreground transition-opacity group-hover:opacity-0"
/>
<Tooltip>
<TooltipTrigger asChild={true}>
<div
aria-label="Drag to resize"
onPointerDown={startResize}
onDoubleClick={reset}
className="absolute -inset-1.5 flex cursor-nwse-resize touch-none items-center justify-center rounded-full text-muted-foreground opacity-0 transition-opacity hover:bg-foreground/[0.07] hover:text-foreground group-hover:opacity-100"
>
<HugeiconsIcon
icon={ArrowExpandDiagonal01Icon}
strokeWidth={2}
className="size-3.5"
/>
</div>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
Drag to resize, double-click to reset
</TooltipContent>
</Tooltip>
</div>
<span className="min-w-0 flex-1 truncate text-ui-12p5 font-semibold text-foreground">
Loaded models
</span>
@ -245,8 +293,14 @@ export function LoadedModelsIndicator({
</TooltipContent>
</Tooltip>
</div>
{/* Capped so four resident runtimes still leave the banners on screen. */}
<div className="flex max-h-[min(272px,42dvh)] min-h-0 flex-col gap-0.5 overflow-y-auto">
{/* Capped so four resident runtimes still leave the banners on screen,
unless the user has given the card a height of their own. */}
<div
className={cn(
"flex min-h-0 flex-col gap-0.5 overflow-y-auto",
sized ? "flex-1" : "max-h-[min(272px,42dvh)]",
)}
>
{entries.map((entry) => (
<LoadedModelRow
key={entry.id}

View file

@ -12,6 +12,7 @@ export const LOADED_MODELS_PREFERENCE_KEYS = {
show: "unsloth_show_loaded_models_indicator",
collapsed: "unsloth_loaded_models_collapsed",
position: "unsloth_loaded_models_position",
size: "unsloth_loaded_models_size",
} as const;
const STORAGE_KEY = LOADED_MODELS_PREFERENCE_KEYS.show;

View file

@ -1,14 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// Pointer drag for the indicator, in the Live monitor's idiom: anchored to its
// corner until the user moves it, then kept where they left it. Absolute
// viewport coordinates rather than a transform, so the position survives a
// reload and can be clamped when the window changes size.
// Pointer drag and resize for the indicator, in the Live monitor's idiom:
// anchored to its corner until the user moves it, then kept where they left it.
// Absolute viewport coordinates rather than a transform, so the geometry
// survives a reload and can be clamped when the window changes size.
import { useCallback, useEffect, useRef, useState } from "react";
export type DragPosition = { left: number; top: number };
export type PanelSize = { width: number; height: number };
/** Keeps the panel fully on screen, and off the very edge. */
const MARGIN = 8;
@ -17,6 +18,11 @@ const MARGIN = 8;
// handle and the button that expands it.
const DRAG_THRESHOLD_PX = 4;
// Small enough to be worth shrinking to, wide enough that a row still fits its
// label and eject button, tall enough to keep the header and one row.
const MIN_WIDTH = 216;
const MIN_HEIGHT = 116;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
@ -49,27 +55,70 @@ export function passedDragThreshold(dx: number, dy: number): boolean {
return Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX;
}
/** Keeps a resized panel between its floor and the room it was given. */
export function clampSize(
size: PanelSize,
maxWidth: number,
maxHeight: number,
): PanelSize {
return {
width: clamp(size.width, MIN_WIDTH, Math.max(MIN_WIDTH, maxWidth)),
height: clamp(size.height, MIN_HEIGHT, Math.max(MIN_HEIGHT, maxHeight)),
};
}
export type ResizeStart = DragPosition & PanelSize;
/**
* Resize from the top-left grip. The card is anchored bottom-right, where there
* is no room to grow, so that corner stays put and the box opens up and to the
* left instead. Exported pure for the node suite.
*/
export function resizeFromTopLeft(
start: ResizeStart,
dx: number,
dy: number,
): { position: DragPosition; size: PanelSize } {
const right = start.left + start.width;
const bottom = start.top + start.height;
// Only what lies that side of the held corner is available, less the margin.
const size = clampSize(
{ width: start.width - dx, height: start.height - dy },
right - MARGIN,
bottom - MARGIN,
);
// Derived from the clamped size, so the held corner cannot drift.
return {
position: { left: right - size.width, top: bottom - size.height },
size,
};
}
function viewport(): Viewport {
return { width: window.innerWidth, height: window.innerHeight };
}
function readStored(key: string): DragPosition | null {
/** One stored `{ left, top }` or `{ width, height }`, or null if unusable. */
function readStored<T extends DragPosition | PanelSize>(
key: string,
keys: (keyof T)[],
): T | null {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<DragPosition>;
return typeof parsed.left === "number" && typeof parsed.top === "number"
? { left: parsed.left, top: parsed.top }
const parsed = JSON.parse(raw) as Partial<Record<keyof T, unknown>>;
return keys.every((name) => typeof parsed[name] === "number")
? (parsed as T)
: null;
} catch {
return null;
}
}
function store(key: string, position: DragPosition | null): void {
function store(key: string, value: object | null): void {
try {
if (position) {
localStorage.setItem(key, JSON.stringify(position));
if (value) {
localStorage.setItem(key, JSON.stringify(value));
} else {
localStorage.removeItem(key);
}
@ -81,22 +130,38 @@ function store(key: string, position: DragPosition | null): void {
export type UseDragPosition = {
/** null until the user moves it, so the default anchor still applies. */
position: DragPosition | null;
/** null until the user resizes it, so the card keeps its natural size. */
size: PanelSize | null;
panelRef: React.RefObject<HTMLDivElement | null>;
startDrag: (event: React.PointerEvent<HTMLElement>) => void;
startResize: (event: React.PointerEvent<HTMLElement>) => void;
dragging: boolean;
resizing: boolean;
/** Back to the natural size and the default corner. */
reset: () => void;
/** True once, for the click that ends a drag, so a handle can also be a
* button. Reading it clears the flag, so a later keyboard activation on the
* same button is not swallowed too. */
justDragged: () => boolean;
};
export function useDragPosition(storageKey: string): UseDragPosition {
export type PanelStorageKeys = { position: string; size: string };
export function useDragPosition(keys: PanelStorageKeys): UseDragPosition {
const [position, setPosition] = useState<DragPosition | null>(() =>
typeof window === "undefined" ? null : readStored(storageKey),
typeof window === "undefined"
? null
: readStored<DragPosition>(keys.position, ["left", "top"]),
);
const [size, setSize] = useState<PanelSize | null>(() =>
typeof window === "undefined"
? null
: readStored<PanelSize>(keys.size, ["width", "height"]),
);
// Held from pointerdown to pointerup; dragging only once past the threshold.
const [pressing, setPressing] = useState(false);
const [dragging, setDragging] = useState(false);
const [resizing, setResizing] = useState(false);
const movedRef = useRef(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const sessionRef = useRef<{
@ -108,6 +173,9 @@ export function useDragPosition(storageKey: string): UseDragPosition {
width: number;
height: number;
} | null>(null);
const resizeSessionRef = useRef<
(ResizeStart & { pointerId: number; startX: number; startY: number }) | null
>(null);
// Returning the same object when nothing changed matters: this also runs from
// a ResizeObserver, and a fresh object every time would re-render forever.
@ -123,8 +191,10 @@ export function useDragPosition(storageKey: string): UseDragPosition {
// A window that shrank, or a panel that grew when expanded, would otherwise
// strand it off screen with nothing able to bring it back.
// Skipped mid-resize: resizeFromTopLeft already holds the box on screen, and
// clamping against a size that is still changing would fight it.
useEffect(() => {
if (!position) return;
if (!position || resizing) return;
const panel = panelRef.current;
const measure = () => {
const box = panel?.getBoundingClientRect();
@ -137,7 +207,7 @@ export function useDragPosition(storageKey: string): UseDragPosition {
window.removeEventListener("resize", measure);
observer?.disconnect();
};
}, [position, reclamp]);
}, [position, resizing, reclamp]);
const startDrag = useCallback((event: React.PointerEvent<HTMLElement>) => {
const panel = panelRef.current;
@ -196,11 +266,71 @@ export function useDragPosition(storageKey: string): UseDragPosition {
};
}, [pressing]);
// Persist the resting place only, not every frame of the drag.
const startResize = useCallback((event: React.PointerEvent<HTMLElement>) => {
const panel = panelRef.current;
if (event.button !== 0 || !panel) return;
// The grip sits on the card, which the header drag handle does not cover,
// but stop here anyway so a resize can never also start a drag.
event.preventDefault();
event.stopPropagation();
const box = panel.getBoundingClientRect();
resizeSessionRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
left: box.left,
top: box.top,
width: box.width,
height: box.height,
};
// Pin it: the card flows in the bottom-right stack until now, and holding a
// corner still means owning both the position and the size.
setPosition({ left: box.left, top: box.top });
setSize({ width: box.width, height: box.height });
setResizing(true);
}, []);
useEffect(() => {
if (pressing) return;
store(storageKey, position);
}, [pressing, position, storageKey]);
if (!resizing) return;
const onMove = (event: PointerEvent) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) return;
event.preventDefault();
const next = resizeFromTopLeft(
session,
event.clientX - session.startX,
event.clientY - session.startY,
);
setPosition(next.position);
setSize(next.size);
};
const onEnd = (event: PointerEvent) => {
const session = resizeSessionRef.current;
if (session && session.pointerId !== event.pointerId) return;
resizeSessionRef.current = null;
setResizing(false);
};
window.addEventListener("pointermove", onMove, { passive: false });
window.addEventListener("pointerup", onEnd);
window.addEventListener("pointercancel", onEnd);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onEnd);
window.removeEventListener("pointercancel", onEnd);
};
}, [resizing]);
// Persist the resting geometry only, not every frame of a drag or resize.
useEffect(() => {
if (pressing || resizing) return;
store(keys.position, position);
store(keys.size, size);
}, [pressing, resizing, position, size, keys.position, keys.size]);
const reset = useCallback(() => {
setPosition(null);
setSize(null);
}, []);
const justDragged = useCallback(() => {
const moved = movedRef.current;
@ -208,5 +338,15 @@ export function useDragPosition(storageKey: string): UseDragPosition {
return moved;
}, []);
return { position, panelRef, startDrag, dragging, justDragged };
return {
position,
size,
panelRef,
startDrag,
startResize,
dragging,
resizing,
reset,
justDragged,
};
}

View file

@ -132,6 +132,7 @@ const PREFS_KEYS: string[] = [
LOADED_MODELS_PREFERENCE_KEYS.show,
LOADED_MODELS_PREFERENCE_KEYS.collapsed,
LOADED_MODELS_PREFERENCE_KEYS.position,
LOADED_MODELS_PREFERENCE_KEYS.size,
// Voice settings
"unsloth_voice_settings",
];

View file

@ -5,8 +5,10 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
clampSize,
clampToViewport,
passedDragThreshold,
resizeFromTopLeft,
} from "../src/features/loaded-models/use-drag-position.ts";
const VIEWPORT = { width: 1440, height: 900 };
@ -74,3 +76,61 @@ test("a window smaller than the card still leaves it reachable", () => {
);
assert.deepEqual(clamped, { left: 8, top: 8 });
});
/** The card where it rests by default: bottom-right, inset 16. */
const RESTING = {
left: VIEWPORT.width - 16 - CARD.width,
top: VIEWPORT.height - 16 - CARD.height,
width: CARD.width,
height: CARD.height,
};
// The whole point of the top-left grip: the corner it is anchored to has no
// room, so growing has to happen on the other side.
test("resizing holds the bottom-right corner still", () => {
const grown = resizeFromTopLeft(RESTING, -120, -80);
assert.equal(grown.size.width, CARD.width + 120);
assert.equal(grown.size.height, CARD.height + 80);
assert.equal(
grown.position.left + grown.size.width,
RESTING.left + RESTING.width,
);
assert.equal(
grown.position.top + grown.size.height,
RESTING.top + RESTING.height,
);
});
test("dragging the grip inwards shrinks the card", () => {
const shrunk = resizeFromTopLeft(RESTING, 40, 20);
assert.equal(shrunk.size.width, CARD.width - 40);
assert.equal(shrunk.size.height, CARD.height - 20);
assert.equal(shrunk.position.left, RESTING.left + 40);
});
test("a resize cannot push the card past the top-left edge", () => {
const huge = resizeFromTopLeft(RESTING, -5000, -5000);
assert.equal(huge.position.left, 8);
assert.equal(huge.position.top, 8);
// Still anchored, so the box is exactly the room that was available.
assert.equal(huge.size.width, RESTING.left + RESTING.width - 8);
assert.equal(huge.size.height, RESTING.top + RESTING.height - 8);
});
test("a resize cannot shrink the card below its floor", () => {
const tiny = resizeFromTopLeft(RESTING, 5000, 5000);
assert.equal(tiny.size.width, 216);
assert.equal(tiny.size.height, 116);
// The floor wins, and the held corner still does not move.
assert.equal(
tiny.position.left + tiny.size.width,
RESTING.left + RESTING.width,
);
});
test("a floor larger than the room left still returns the floor", () => {
assert.deepEqual(clampSize({ width: 300, height: 300 }, 10, 10), {
width: 216,
height: 116,
});
});