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 ? (
-
- Cancel
-
- ) : 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;
}