diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index 758aa00e1a..83452c2ea8 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -1607,6 +1607,14 @@ export function ChatPage({
await selectModel(selection);
return;
}
+ // Refuse staging while a load is in flight (it would be silently dropped);
+ // the immediate-load branch above is already guarded in selectModel.
+ if (store.modelLoading) {
+ toast.info("Another model is already loading", {
+ description: "Wait for it to finish or cancel it first.",
+ });
+ return;
+ }
// Tear down any existing staged pick first so its in-flight download is
// cancelled, not left running after we rebind to the new pick.
abandonStaged();
@@ -2495,6 +2503,7 @@ export function ChatPage({
);
}}
externalProviderType={activeExternalProviderType}
+ loadingModel={loadingModel}
onReloadModel={() => {
const state = useChatRuntimeStore.getState();
if (state.params.checkpoint) {
@@ -2512,22 +2521,23 @@ export function ChatPage({
if (!pending) return;
const keyAtLoad = chatContextKey;
// forceReload: the staged model isn't loaded yet, so bypass the
- // same-checkpoint dedupe (and selectModel clears pendingSelection).
- // keepSpeculative: honor the speculative mode set on the sidebar.
+ // same-checkpoint dedupe. keepSpeculative: honor the speculative mode
+ // set on the sidebar.
void selectModel({
...pending,
forceReload: true,
keepSpeculative: true,
throwOnError: true,
}).catch(() => {
- // Recoverable failure (expired token, gated repo, OOM…): selectModel
- // cleared the pick but left the edited knobs intact.
+ // Recoverable failure (expired token, gated repo, OOM…): the pick is
+ // cleared only on success, so it normally stays staged with edited
+ // knobs intact — nothing to restore.
const store = useChatRuntimeStore.getState();
- // A pick staged meanwhile owns the knobs now; leave it untouched.
+ // Still staged (this pick, or a newer one queued meanwhile): leave it.
if (store.pendingSelection) return;
- // Restore (not re-stage, which would reset the knobs) only if the
- // staged-load is still wanted: same chat context, sheet still open,
- // page still mounted.
+ // Cleared mid-load (sheet closed / switched chats). Re-stage only if
+ // the staged-load is still wanted: same chat context, sheet still
+ // open, page still mounted.
const stillWanted =
mountedRef.current &&
store.settingsPanelOpen &&
diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
index bf0cfcb277..77d7cf4c6d 100644
--- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx
+++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
@@ -52,6 +52,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { Slider } from "@/components/ui/slider";
+import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { InfoHint } from "@/components/ui/info-hint";
@@ -99,6 +100,7 @@ import {
} from "./provider-capabilities";
import {
isPendingGguf,
+ pendingSelectionMatches,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
import { RetrievalSettingsSection } from "@/features/rag/components/retrieval-settings-section";
@@ -145,6 +147,7 @@ function NumericValueInput({
className,
ariaLabel,
size: sizeAttr,
+ disabled = false,
}: {
value: number;
min?: number;
@@ -155,6 +158,7 @@ function NumericValueInput({
className?: string;
ariaLabel?: string;
size?: number;
+ disabled?: boolean;
}) {
const [focused, setFocused] = useState(false);
const [draft, setDraft] = useState("");
@@ -177,6 +181,7 @@ function NumericValueInput({
void;
+ /** The in-flight load (id + GGUF variant + native path token), or null when
+ * idle. Used to show a loading state for the staged pick only — not for an
+ * unrelated load or a cancel's background unload. */
+ loadingModel?: {
+ id: string;
+ ggufVariant?: string | null;
+ nativePathToken?: string | null;
+ } | null;
/** Loads the staged `pendingSelection` (deferred "Load on selection" flow). */
onLoadPendingModel?: () => void;
/** Download progress (0–1) for a staged GGUF being fetched, or null when idle. */
@@ -457,6 +470,7 @@ export function ChatSettingsPanel({
onExternalProviderChange,
externalProviderType = null,
onReloadModel,
+ loadingModel = null,
onLoadPendingModel,
stagedDownloadFraction,
onCancelStagedDownload,
@@ -475,6 +489,19 @@ export function ChatSettingsPanel({
!isExternalModel || Boolean(providerCapabilities?.presencePenalty);
const isMobile = useIsMobile();
const pendingSelection = useChatRuntimeStore((s) => s.pendingSelection);
+ // "Loading" only when the in-flight load IS this staged pick (full id + GGUF
+ // variant + native token match), not an unrelated load or a cancel's
+ // background unload. The variant matters: a different quant of the same repo
+ // staged mid-load must not read as this one loading.
+ const stagedLoading =
+ loadingModel != null &&
+ pendingSelectionMatches(pendingSelection, {
+ id: loadingModel.id,
+ ggufVariant: loadingModel.ggufVariant,
+ nativePathToken: loadingModel.nativePathToken,
+ });
+ // Load settings are snapshotted at click time; lock them while loading.
+ const modelControlsDisabled = stagedLoading;
const abandonStagedModel = useChatRuntimeStore((s) => s.abandonStagedModel);
const resetModelSettingsToLoaded = useChatRuntimeStore(
(s) => s.resetModelSettingsToLoaded,
@@ -867,10 +894,14 @@ export function ChatSettingsPanel({
{pendingSelection && (
- {stagedLabel} is staged, not loaded yet
+ {stagedLoading
+ ? `Loading ${stagedLabel}…`
+ : `${stagedLabel} is staged, not loaded yet`}
- Set the options below, then choose Load model to load it.
+ {stagedLoading
+ ? "Applying your settings."
+ : "Set the options below, then choose Load model to load it."}
)}
@@ -898,6 +929,7 @@ export function ChatSettingsPanel({
}}
ariaLabel="Context Length"
size={8}
+ disabled={modelControlsDisabled}
/>
{ggufMaxContextLength != null &&
typeof ctxDisplayValue === "number" &&
@@ -944,6 +977,7 @@ export function ChatSettingsPanel({