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