mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-17 04:43:52 +00:00
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:
parent
fb3ba5c3b5
commit
96749d1f0a
5 changed files with 289 additions and 33 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue