diff --git a/studio/frontend/src/components/ui/sonner.tsx b/studio/frontend/src/components/ui/sonner.tsx
index aec1235b81..4c65edc4b7 100644
--- a/studio/frontend/src/components/ui/sonner.tsx
+++ b/studio/frontend/src/components/ui/sonner.tsx
@@ -8,8 +8,8 @@ import {
MultiplicationSignCircleIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { Spinner } from "@/components/ui/spinner";
import { useTheme } from "@/features/settings/stores/theme-store";
+import { createLoadingToastIcon } from "@/lib/toast";
import { Toaster as Sonner, type ToasterProps } from "sonner";
// Make toast text selectable. Sonner's onPointerDown calls setPointerCapture(),
@@ -78,7 +78,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
/>
),
// App-wide arc spinner so loading toasts match the "Downloading model" toast.
- loading: ,
+ loading: createLoadingToastIcon(),
}}
style={
{
diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts
index fb9331ecc2..a0be3ea640 100644
--- a/studio/frontend/src/features/chat/api/chat-adapter.ts
+++ b/studio/frontend/src/features/chat/api/chat-adapter.ts
@@ -6,7 +6,7 @@ import { resolveInitialConfig } from "@/features/model-picker";
import { projectHasSources } from "@/features/rag/api/rag-api";
import { apiUrl } from "@/lib/api-base";
import { parseParamCountB } from "@/lib/model-size";
-import { toast } from "@/lib/toast";
+import { createLoadingToastIcon, toast } from "@/lib/toast";
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
import type { ChatModelAdapter } from "@assistant-ui/react";
import { parsePartialJsonObject } from "assistant-stream/utils";
@@ -1512,13 +1512,38 @@ async function autoLoadSmallestModel(): Promise<{
const trustRemoteCode = store.params.trustRemoteCode ?? false;
const specSettings = resolveSpeculativeSettingsForLoad();
const lastLoaded = readLastLocalModelLoad();
- const toastId = toast("Loading a model…", {
+ let autoLoadToastDismissed = false;
+ const toastId = toast.message("Loading a model…", {
description: lastLoaded
? "Loading last used model."
: "Auto-selecting the smallest downloaded model.",
- duration: 5000,
+ duration: Number.POSITIVE_INFINITY,
closeButton: true,
+ icon: createLoadingToastIcon(),
+ onDismiss: () => {
+ autoLoadToastDismissed = true;
+ },
});
+ const updateAutoLoadToast = (message: string, description: string): void => {
+ if (autoLoadToastDismissed) return;
+ toast.message(message, {
+ id: toastId,
+ description,
+ duration: Number.POSITIVE_INFINITY,
+ });
+ };
+ const showAutoLoadSuccess = (message: string): void => {
+ const options = {
+ description: undefined,
+ duration: 5000,
+ icon: undefined,
+ };
+ if (autoLoadToastDismissed) {
+ toast.success(message, options);
+ return;
+ }
+ toast.success(message, { ...options, id: toastId });
+ };
let blockedByTrustRemoteCode = false;
let hadNonTrustFailure = false;
let loadAttempts = 0;
@@ -1774,7 +1799,7 @@ async function autoLoadSmallestModel(): Promise<{
ggufVariant: candidate.ggufVariant,
});
}
- toast.success(candidate.successLabel, { id: toastId });
+ showAutoLoadSuccess(candidate.successLabel);
return true;
}
try {
@@ -1800,11 +1825,10 @@ async function autoLoadSmallestModel(): Promise<{
isAutoLoadableGgufVariant(entry),
);
if (variant) {
- toast("Loading last used model…", {
- id: toastId,
- description: `${repo.repo_id} (${variant.quant})`,
- duration: 5000,
- });
+ updateAutoLoadToast(
+ "Loading last used model…",
+ `${repo.repo_id} (${variant.quant})`,
+ );
if (
await loadAutoLoadCandidate({
id: repo.repo_id,
@@ -1829,11 +1853,7 @@ async function autoLoadSmallestModel(): Promise<{
const repo = findCachedRepo(modelRepos, lastLoaded.id);
if (repo) {
try {
- toast("Loading last used model…", {
- id: toastId,
- description: repo.repo_id,
- duration: 5000,
- });
+ updateAutoLoadToast("Loading last used model…", repo.repo_id);
if (
await loadAutoLoadCandidate({
id: repo.repo_id,
@@ -1854,11 +1874,10 @@ async function autoLoadSmallestModel(): Promise<{
}
}
}
- toast("Loading a model…", {
- id: toastId,
- description: "Auto-selecting the smallest downloaded model.",
- duration: 5000,
- });
+ updateAutoLoadToast(
+ "Loading a model…",
+ "Auto-selecting the smallest downloaded model.",
+ );
}
// GGUF first: smallest-total-size repo, then its smallest variant.
@@ -1949,12 +1968,10 @@ async function autoLoadSmallestModel(): Promise<{
}
// No cached models — try downloading a small default GGUF.
- toast("Downloading a small model…", {
- id: toastId,
- description:
- "No downloaded models found. Fetching Qwen3.5-4B-MTP (UD-Q4_K_XL).",
- duration: 30000,
- });
+ updateAutoLoadToast(
+ "Downloading a small model…",
+ "No downloaded models found. Fetching Qwen3.5-4B-MTP (UD-Q4_K_XL).",
+ );
try {
const rt = useChatRuntimeStore.getState();
if (
@@ -2050,7 +2067,7 @@ async function autoLoadSmallestModel(): Promise<{
kind: "gguf",
ggufVariant: "UD-Q4_K_XL",
});
- toast.success("Loaded Qwen3.5-4B-MTP (UD-Q4_K_XL)", { id: toastId });
+ showAutoLoadSuccess("Loaded Qwen3.5-4B-MTP (UD-Q4_K_XL)");
return { loaded: true, blockedByTrustRemoteCode: false };
} catch {
toast.dismiss(toastId);
diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts
index a067855fd5..bc4bc2a391 100644
--- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts
+++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts
@@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { getInferenceStatus, loadModel } from "@/features/chat";
-import { toast } from "@/lib/toast";
+import { createLoadingToastIcon, toast } from "@/lib/toast";
import { toastError } from "@/shared/toast";
import { useCallback, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
@@ -238,8 +238,15 @@ async function loadLocalModelSelection(
): Promise {
const { target, ggufVariant } = selection;
const modelLabel = ggufVariant ? `${target} (${ggufVariant})` : target;
- const toastId = toast.loading(`Loading ${modelLabel}...`, {
+ let loadToastDismissed = false;
+ const toastId = toast.message(`Loading ${modelLabel}...`, {
description: "Starting the local inference server for this recipe.",
+ duration: Number.POSITIVE_INFINITY,
+ closeButton: true,
+ icon: createLoadingToastIcon(),
+ onDismiss: () => {
+ loadToastDismissed = true;
+ },
});
try {
const isGguf = GGUF_MODEL_PATTERN.test(target) || Boolean(ggufVariant);
@@ -267,7 +274,16 @@ async function loadLocalModelSelection(
// biome-ignore lint/style/useNamingConvention: api schema
tensor_parallel: false,
});
- toast.success(`Loaded ${modelLabel}`, { id: toastId, duration: 2000 });
+ const successOptions = {
+ description: undefined,
+ duration: 2000,
+ icon: undefined,
+ };
+ if (loadToastDismissed) {
+ toast.success(`Loaded ${modelLabel}`, successOptions);
+ } else {
+ toast.success(`Loaded ${modelLabel}`, { ...successOptions, id: toastId });
+ }
return null;
} catch (error) {
toast.dismiss(toastId);
diff --git a/studio/frontend/src/lib/toast.ts b/studio/frontend/src/lib/toast.ts
index 6b1635b42e..b500c11ec2 100644
--- a/studio/frontend/src/lib/toast.ts
+++ b/studio/frontend/src/lib/toast.ts
@@ -4,5 +4,15 @@
// Re-export of sonner. Swipe blocking lives on the Toaster via
// `swipeDirections={[]}`, so no per-toast dismissible override.
+import { Spinner } from "@/components/ui/spinner";
+import { createElement } from "react";
+
+function createLoadingToastIcon() {
+ return createElement(Spinner, {
+ className: "size-4 text-muted-foreground",
+ });
+}
+
export { toast } from "sonner";
export type { ExternalToast } from "sonner";
+export { createLoadingToastIcon };
diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py
index e1aba66b1b..00ee83efc7 100644
--- a/tests/studio/test_model_picker_contracts.py
+++ b/tests/studio/test_model_picker_contracts.py
@@ -73,6 +73,57 @@ def test_autoload_records_backend_loaded_model_identity():
assert "m.id === loadedModelId" in autoload
+def test_chat_autoload_toast_is_persistent_and_dismissible():
+ """Send-triggered autoload stays visible until it settles but remains
+ dismissible, matching the explicit model-loading toast's lifetime."""
+ src = _read("features/chat/api/chat-adapter.ts")
+ auto_load = src.split("async function autoLoadSmallestModel", 1)[1]
+ auto_load = auto_load.split("export function createOpenAIStreamAdapter", 1)[0]
+ assert "toast.loading(" not in auto_load
+ assert "const updateAutoLoadToast =" in auto_load
+ assert "if (autoLoadToastDismissed) return;" in auto_load
+ assert auto_load.count("toast.message(") == 2
+ assert auto_load.count("updateAutoLoadToast(") >= 4
+ assert "duration: Number.POSITIVE_INFINITY" in auto_load
+ assert "closeButton: true" in auto_load
+ assert "icon: createLoadingToastIcon()" in auto_load
+ assert "onDismiss:" in auto_load
+ # Terminal success uses a fresh finite toast after manual progress dismissal.
+ assert "showAutoLoadSuccess" in auto_load
+ assert "description: undefined" in auto_load
+ assert "icon: undefined" in auto_load
+ assert "duration: 5000" in auto_load
+ assert "duration: 30000" not in auto_load
+ assert auto_load.count("toast.dismiss(toastId)") >= 4
+
+ explicit_load = _read("features/chat/hooks/use-chat-model-runtime.ts")
+ assert "duration: Infinity" in explicit_load
+
+
+def test_recipe_model_load_toast_is_persistent_and_dismissible():
+ """Recipe model loading uses the same dismissible persistent lifecycle as
+ chat loading because both call the non-abortable loadModel API."""
+ src = _read("features/recipe-studio/hooks/use-recipe-executions.ts")
+ model_load = src.split("async function loadLocalModelSelection", 1)[1]
+ model_load = model_load.split("function getLocalModelLoadPlanForPayload", 1)[0]
+ assert "toast.loading(" not in model_load
+ assert "toast.message(" in model_load
+ assert "duration: Number.POSITIVE_INFINITY" in model_load
+ assert "closeButton: true" in model_load
+ assert "icon: createLoadingToastIcon()" in model_load
+ assert "onDismiss:" in model_load
+ assert "description: undefined" in model_load
+ assert "icon: undefined" in model_load
+ assert "duration: 2000" in model_load
+
+ toast_lib = _read("lib/toast.ts")
+ assert "createElement(Spinner" in toast_lib
+ assert 'className: "size-4 text-muted-foreground"' in toast_lib
+
+ sonner = _read("components/ui/sonner.tsx")
+ assert "loading: createLoadingToastIcon()" in sonner
+
+
def test_rollback_restores_native_lease_expiry_with_token():
"""A failed model switch that rolls back to a previously loaded picked GGUF
must restore the lease expiry paired with the token, never the token alone