From 49d4c61623d55ffd36a5c94979ab651c6fe5c68c Mon Sep 17 00:00:00 2001
From: oobabooga
Date: Mon, 22 Jun 2026 02:19:09 -0300
Subject: [PATCH] studio/frontend: Prevent staged model's load settings from
disappearing while it loads (#6458)
* Studio: keep staged model's load settings visible while it loads
* Studio: pin staged load settings at click time, match loading pick by variant
* Studio: drop any stale staged pick after a successful load
* Studio: disable staged Load button while a different model loads
When a model is staged with "Load on selection" off and a different model (or a different GGUF variant of the same repo) is already loading, selectModel's in-flight-load guard matches on id + native path token only, so the click was silently deduped to a no-op. The staged Load button stayed enabled but did nothing, and the stale stage was then cleared once the other load finished.
Disable the staged Load button (showing "Another model loading...") whenever a different model is loading, so it is no longer an enabled no-op. The stage stays put and the user can retry once the in-flight load settles.
* Studio: refuse loading a different model while one is in flight
The in-flight-load guard in selectModel matched on id + native path token only, so a different GGUF variant of the same repo fell through and was silently deduped to a no-op. The earlier commit disabled the staged Load button for this case, but other entry points (e.g. selecting a different quant from the model picker with "Load on selection" on) still hit selectModel directly and no-op'd.
Make the guard variant-aware (id + GGUF variant + native path token) and, for a genuinely different model while a load is in flight, surface a "Another model is already loading" toast instead of silently returning. The load path has no clean supersession, so a second concurrent load is not started; the user is told to wait or cancel. Centralized in selectModel so every entry point is covered.
* Studio: lock staged model's load settings while it loads
The staged Load button snapshots context length, KV cache dtype, speculative
decoding, draft tokens, and tensor parallelism at click time, but the settings
sheet stays mounted during the load, so edits made while "Loading..." shows were
silently ignored and then overwritten by the load response. Disable those
controls while the staged pick is loading so the visible state matches what the
run actually uses.
* Studio: refuse to stage a model while another load is in flight
With Load on selection off, selecting a model mid-load staged it into
pendingSelection and showed a disabled "Another model loading..." button,
implying the user could retry once the load settled. The post-load cleanup then
treated that queued pick as stale and silently cancelled and cleared it. Refuse
to stage while a load (or a cancel's background unload) is in flight: stageModel
no-ops in that state so every entry point (the chat selector and the Hub) is
covered, and stageOrLoad surfaces a toast on the common path. The immediate-load
path is unchanged; selectModel already rejects a concurrent load.
* Tighten staged-load guard comments
---------
Co-authored-by: Daniel Han
---
.../frontend/src/features/chat/chat-page.tsx | 26 ++--
.../src/features/chat/chat-settings-sheet.tsx | 96 ++++++++++---
.../chat/hooks/use-chat-model-runtime.ts | 135 ++++++++++++------
.../hooks/use-staged-model-preparation.ts | 12 +-
.../chat/stores/chat-runtime-store.ts | 24 +++-
5 files changed, 211 insertions(+), 82 deletions(-)
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({