diff --git a/studio/frontend/src/components/ui/sonner.tsx b/studio/frontend/src/components/ui/sonner.tsx index 5bd3078761..bf6d4970b0 100644 --- a/studio/frontend/src/components/ui/sonner.tsx +++ b/studio/frontend/src/components/ui/sonner.tsx @@ -65,13 +65,12 @@ const Toaster = ({ ...props }: ToasterProps) => { "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", "--border-radius": "var(--radius)", - // Pin close button to the top-right corner inside the toast. - // Overrides sonner's default left placement and outside-corner - // translate; top offset is set via a rule in index.css since sonner - // hardcodes `top: 0` (not a CSS variable). - "--toast-close-button-start": "unset", - "--toast-close-button-end": "8px", - "--toast-close-button-transform": "none", + // Pin the close button inside the toast's top-right corner. + // Sonner defaults to the left/outside edge, so keep the horizontal + // override here and the top offset in index.css. + "--toast-close-button-start": "unset", + "--toast-close-button-end": "8px", + "--toast-close-button-transform": "none", } as React.CSSProperties } // No swipe gestures; keeps toast text selectable. diff --git a/studio/frontend/src/features/chat/components/model-load-status.tsx b/studio/frontend/src/features/chat/components/model-load-status.tsx index 381f58fbbf..75d92f4581 100644 --- a/studio/frontend/src/features/chat/components/model-load-status.tsx +++ b/studio/frontend/src/features/chat/components/model-load-status.tsx @@ -10,7 +10,6 @@ type ModelLoadDescriptionProps = { message?: string | null; progressPercent?: number | null; progressLabel?: string | null; - onStop?: () => void; }; function clampProgress(value: number): number { @@ -45,7 +44,6 @@ export function ModelLoadDescription({ message, progressPercent, progressLabel, - onStop, }: ModelLoadDescriptionProps) { const hasProgress = typeof progressPercent === "number"; // Split once at the top of the render so the JSX below stays flat -- @@ -58,7 +56,7 @@ export function ModelLoadDescription({
-
+
{title ?

{title}

: null} {hasProgress ? (
@@ -82,18 +80,6 @@ export function ModelLoadDescription({

{message}

) : null}
- {onStop ? ( - - ) : null}
); } diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 1cac12ab13..8771bc888a 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -56,10 +56,16 @@ type SelectedModelInput = { }; const MODEL_LOAD_TOAST_CLASSNAMES = { - toast: "items-start gap-2.5", + toast: "chat-model-load-toast items-center gap-2.5", content: "gap-0.5 flex-1 min-w-0", title: "leading-5", description: "mt-0 w-full", + cancelButton: + "!h-auto !rounded-none !border-0 !bg-transparent !px-1 !text-[11px] !font-normal !text-muted-foreground hover:!bg-transparent hover:!text-destructive focus-visible:!text-destructive", +} as const; + +const MODEL_LOADED_TOAST_CLASSNAMES = { + toast: "chat-model-loaded-toast items-center gap-2.5", } as const; const LORA_SUFFIX_RE = /_(\d{9,})$/; @@ -78,6 +84,12 @@ function stripTrailingEpoch(input: string): string { return cleaned || input; } +function shortModelLabel(idOrName: string): string { + const slash = idOrName.lastIndexOf("/"); + const label = slash >= 0 ? idOrName.slice(slash + 1) : idOrName; + return label || idOrName; +} + function describeModel(model: { is_lora?: boolean; is_vision?: boolean; @@ -233,14 +245,12 @@ export function useChatModelRuntime() { message: string, progressPercent?: number | null, progressLabel?: string | null, - onStop?: () => void, ) => createElement(ModelLoadDescription, { title, message, progressPercent, progressLabel, - onStop, }), [], ); @@ -471,10 +481,11 @@ export function useChatModelRuntime() { const isLora = explicitIsLora ?? model?.isLora ?? loraIsAdapter ?? false; const displayName = model?.name || lora?.name || modelId; + const toastDisplayName = shortModelLabel(displayName); const loadAttemptId = ++loadAttemptRef.current; primeNativeNotificationPermission().catch(() => undefined); const notificationModelKey = `${modelId}:${ggufVariant ?? ""}:${loadAttemptId}`; - const safeModelName = safeNotificationLabel(displayName, "The model"); + const safeModelName = safeNotificationLabel(toastDisplayName, "The model"); const currentCheckpoint = useChatRuntimeStore.getState().params.checkpoint; const previousCheckpoint = currentCheckpoint; @@ -794,25 +805,32 @@ export function useChatModelRuntime() { const isCachedLoad = isDownloaded || isCachedLora; const toastTitle = isCachedLoad ? "Starting model…" : "Downloading model…"; + const modelLoadToastOptions = (description: ReturnType) => ({ + description, + duration: Infinity, + closeButton: true, + cancel: { + label: "Cancel", + onClick: cancelLoading, + }, + classNames: MODEL_LOAD_TOAST_CLASSNAMES, + onDismiss: (dismissedToast: { id: string | number }) => { + if (loadToastIdRef.current !== dismissedToast.id) { + return; + } + setLoadToastDismissedState(true); + }, + }); const toastId = toast( null, - { - description: renderLoadDescription( + modelLoadToastOptions( + renderLoadDescription( toastTitle, loadingDescription, isCachedLoad ? null : 0, isCachedLoad ? null : "Preparing download", - cancelLoading, ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) { - return; - } - setLoadToastDismissedState(true); - }, - }, + ), ); loadToastIdRef.current = toastId; @@ -919,19 +937,14 @@ export function useChatModelRuntime() { if (loadToastDismissedRef.current) return; toast(null, { id: toastId, - description: renderLoadDescription( - "Downloading model…", - loadingDescription, - pct, - progressLabel, - cancelLoading, + ...modelLoadToastOptions( + renderLoadDescription( + "Downloading model…", + loadingDescription, + pct, + progressLabel, + ), ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) return; - setLoadToastDismissedState(true); - }, }); } else if ( prog.downloaded_bytes > 0 && @@ -958,19 +971,14 @@ export function useChatModelRuntime() { if (!loadToastDismissedRef.current) { toast(null, { id: toastId, - description: renderLoadDescription( - "Starting model…", - "Download complete. Loading the model into memory.", - 100, - "Download complete", - cancelLoading, + ...modelLoadToastOptions( + renderLoadDescription( + "Starting model…", + "Download complete. Loading the model into memory.", + 100, + "Download complete", + ), ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) return; - setLoadToastDismissedState(true); - }, }); } notifyNative({ @@ -1020,19 +1028,14 @@ export function useChatModelRuntime() { if (loadToastDismissedRef.current) return; toast(null, { id: toastId, - description: renderLoadDescription( - "Starting model…", - "Paging weights into memory.", - pct, - label, - cancelLoading, + ...modelLoadToastOptions( + renderLoadDescription( + "Starting model…", + "Paging weights into memory.", + pct, + label, + ), ), - duration: Infinity, - classNames: MODEL_LOAD_TOAST_CLASSNAMES, - onDismiss: (dismissedToast) => { - if (loadToastIdRef.current !== dismissedToast.id) return; - setLoadToastDismissedState(true); - }, }); } catch { // Ignore polling errors. @@ -1054,12 +1057,20 @@ export function useChatModelRuntime() { try { await performLoad(); if (loadToastDismissedRef.current) { - toast.success(`${displayName} loaded`); + toast.success(`${toastDisplayName} loaded`, { + classNames: MODEL_LOADED_TOAST_CLASSNAMES, + closeButton: true, + duration: 8000, + }); } else { - toast.success(`${displayName} loaded`, { + toast.success(`${toastDisplayName} loaded`, { id: toastId, description: undefined, + cancel: undefined, + classNames: MODEL_LOADED_TOAST_CLASSNAMES, + closeButton: true, duration: 8000, + onDismiss: undefined, }); } notifyNative({ @@ -1078,7 +1089,11 @@ export function useChatModelRuntime() { toast.error(message, { id: toastId, description: undefined, + cancel: undefined, + classNames: undefined, + closeButton: true, duration: 8000, + onDismiss: undefined, }); } notifyNative({ diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 28c4cd0948..12d63aac88 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -1225,10 +1225,42 @@ /* Lighter shadow + tighter vertical padding than Sonner's defaults; !important because Sonner injects its base rules at runtime. */ [data-sonner-toast][data-styled='true'] { - padding: 10px 16px !important; + padding: 12px 18px !important; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08) !important; } +[data-sonner-toast][data-styled='true']:has([data-close-button]):not(:has([data-cancel])) { + padding-right: 48px !important; +} + +[data-sonner-toast][data-styled='true']:has([data-cancel]):has([data-close-button]) { + padding-right: 88px !important; +} + +[data-sonner-toast][data-styled='true'].chat-model-load-toast, +[data-sonner-toast][data-styled='true'].chat-model-loaded-toast { + padding-top: 14px !important; + padding-bottom: 14px !important; +} + +[data-sonner-toast][data-styled='true'].chat-model-loaded-toast [data-close-button] { + top: calc(50% - 0.25px) !important; + transform: translateY(-50%) !important; +} + +[data-sonner-toast][data-styled='true'].chat-model-load-toast:not(:has([data-cancel])) [data-close-button] { + top: calc(50% - 0.25px) !important; + transform: translateY(-50%) !important; +} + +[data-sonner-toast][data-styled="true"]:has([data-cancel]) [data-cancel] { + position: absolute !important; + right: 36px !important; + top: 50% !important; + transform: translateY(-50%) !important; + margin: 0 !important; +} + /* Boost shadow on dark surfaces; mirrors .shadow-border / .menu-soft-surface pattern. */ .dark [data-sonner-toast][data-styled='true'] { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important; @@ -1391,13 +1423,15 @@ mix-blend-mode: normal; } -/* Override sonner top: 0 and pin to theme tokens (--gray2 hover ignores data-sonner-theme). */ +/* Keep Sonner close button inside the toast and pin to theme tokens (--gray2 hover ignores data-sonner-theme). */ [data-sonner-toast][data-styled="true"] [data-close-button] { top: 8px !important; + transform: none !important; background: var(--popover) !important; color: var(--popover-foreground) !important; border-color: var(--border) !important; } + [data-sonner-toast][data-styled="true"] [data-close-button] svg { stroke-width: 2.25; }