;
+ finish_reason?: string | null;
+ }>;
}
- ).choices?.[0]?.finish_reason;
+ ).choices;
+ for (const choice of parsedChoices ?? []) {
+ const delta = choice.delta;
+ if (delta) {
+ const contentState = classifyStructuredDeltaContent(delta.content);
+ sawAssistantContent ||= contentState.hasAssistantContent;
+ sawReasoningContent ||= contentState.hasReasoningContent;
+ const reasoning =
+ delta.reasoning_content ??
+ delta.reasoning ??
+ delta.reasoning_details;
+ sawReasoningContent ||= hasNonWhitespaceText(reasoning);
+ }
+ if (choice.finish_reason) {
+ terminalFinishReason = choice.finish_reason;
+ }
+ }
+ const finishReason = parsedChoices?.[0]?.finish_reason;
if (finishReason) {
sawTerminalSignal = true;
}
diff --git a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx
index 0f4c83e0db..1955c3aca1 100644
--- a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx
+++ b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx
@@ -12,6 +12,8 @@ import {
import { MascotImg } from "@/components/mascot-img";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
+import { downloadFile, isDownloadCancelled } from "@/lib/native-files";
+import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { CopyIcon, EyeIcon, Maximize2Icon, XIcon } from "lucide-react";
import { Download01Icon } from "@hugeicons/core-free-icons";
@@ -91,18 +93,6 @@ function ArtifactGeneratingPanel() {
);
}
-function downloadTextFile(filename: string, text: string): void {
- const blob = new Blob([text], { type: "text/html;charset=utf-8" });
- const url = URL.createObjectURL(blob);
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = filename;
- document.body.appendChild(anchor);
- anchor.click();
- document.body.removeChild(anchor);
- window.setTimeout(() => URL.revokeObjectURL(url), 0);
-}
-
export function ArtifactSurface({
artifact,
variant,
@@ -205,7 +195,7 @@ export function ArtifactSurface({
className={cn(
"relative flex min-h-0 flex-col bg-background",
variant === "panel"
- ? "artifact-panel-shell mx-2 mt-[72px] mb-8 h-[calc(100%_-_104px)] overflow-visible rounded-[28px] border-t border-border/70 bg-card/95"
+ ? "artifact-panel-shell mx-2 mt-[90px] mb-8 h-[calc(100%_-_122px)] overflow-visible rounded-[28px] border-t border-border/70 bg-card/95"
: "h-[min(92vh,900px)] w-[min(96vw,1200px)] overflow-hidden rounded-2xl border border-border shadow-xl",
)}
aria-label={`${artifact.title} canvas`}
@@ -265,7 +255,19 @@ export function ArtifactSurface({
size="icon"
className="size-8"
disabled={isLoadingArtifact || !hasArtifactCode}
- onClick={() => downloadTextFile(filename, artifact.code)}
+ onClick={() => {
+ // Route through the native save dialog on desktop; the plain
+ // blob-anchor download is silently dropped by the Tauri WebView2.
+ void downloadFile(
+ artifact.code,
+ filename,
+ "text/html;charset=utf-8",
+ ).catch((err) => {
+ if (!isDownloadCancelled(err)) {
+ toast.error("Failed to save canvas HTML");
+ }
+ });
+ }}
aria-label="Download canvas HTML"
>
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index 7452cf3447..a439a91239 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -2676,7 +2676,7 @@ export function ChatPage({
config: meta?.config,
nativePathToken: meta?.nativePathToken,
nativePathExpiresAtMs: meta?.nativePathExpiresAtMs,
- forceReload: isSameLoadedModel || undefined,
+ forceReload: meta?.forceReload ?? (isSameLoadedModel || undefined),
};
await stageOrLoad(selection);
})();
diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
index c3a59e9860..7b310c50d4 100644
--- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx
+++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
@@ -966,7 +966,7 @@ export function ChatSettingsPanel({
Delete
-
+
Saving a preset also stores current load settings (context length,
KV cache dtype, speculative decoding, GPU layers).
{currentLoadSummary ? (
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 76a310ac33..48a6168555 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
@@ -62,6 +62,7 @@ import {
import { isExternalModelId } from "../external-providers";
import {
applyPerModelConfigToRuntime,
+ normalizeMaxSeqLength,
type PerModelConfig,
} from "@/features/model-picker";
import type {
@@ -604,12 +605,19 @@ export function useChatModelRuntime() {
async function performLoad(): Promise {
if (abortCtrl.signal.aborted) throw new Error("Cancelled");
let previousWasUnloaded = false;
+ const pendingLoadConfig =
+ typeof selection !== "string" ? selection.config : undefined;
+ if (pendingLoadConfig) {
+ applyPerModelConfigToRuntime(pendingLoadConfig);
+ }
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
const stateBeforeUnload = useChatRuntimeStore.getState();
let trustRemoteCode = stateBeforeUnload.params.trustRemoteCode ?? false;
let approvedRemoteCodeFingerprint: string | null = null;
- const maxSeqLength = stateBeforeUnload.params.maxSeqLength;
+ const maxSeqLength =
+ normalizeMaxSeqLength(pendingLoadConfig?.maxSeqLength) ??
+ stateBeforeUnload.params.maxSeqLength;
const previousActiveNativePathToken =
stateBeforeUnload.activeNativePathToken;
const previousIsGguf =
@@ -643,34 +651,54 @@ export function useChatModelRuntime() {
const previousActiveNativePathExpiresAtMs =
stateBeforeUnload.activeNativePathExpiresAtMs;
// Snapshot the load settings at click time, before the awaits below
- // (validation, the trust dialog, unload).
- const loadChatTemplateOverride = stateBeforeUnload.chatTemplateOverride;
- const loadKvCacheDtype = stateBeforeUnload.kvCacheDtype;
+ // (validation, the trust dialog, unload). When the picker staged a
+ // config payload, prefer it over the store: React may not have
+ // flushed NumericValueInput's blur commit into state yet.
+ const loadChatTemplateOverride =
+ pendingLoadConfig?.chatTemplateOverride?.trim()
+ ? pendingLoadConfig.chatTemplateOverride
+ : stateBeforeUnload.chatTemplateOverride;
+ const loadKvCacheDtype =
+ pendingLoadConfig?.kvCacheDtype ?? stateBeforeUnload.kvCacheDtype;
// gpuMemoryMode is a standing preference (kept across a model switch);
// the rest are per-model knobs the reset below clears, so they are
// re-baselined there in lock-step with the store.
- let loadCustomContextLength = stateBeforeUnload.customContextLength;
+ let loadCustomContextLength =
+ pendingLoadConfig?.customContextLength ??
+ stateBeforeUnload.customContextLength;
const loadGgufContextLength = stateBeforeUnload.ggufContextLength;
- const loadTensorParallel = stateBeforeUnload.tensorParallel;
+ const loadTensorParallel =
+ pendingLoadConfig?.tensorParallel ?? stateBeforeUnload.tensorParallel;
const loadActivePresetSource = stateBeforeUnload.activePresetSource;
const loadActiveGgufVariant = stateBeforeUnload.activeGgufVariant;
- const loadGpuMemoryMode = stateBeforeUnload.gpuMemoryMode;
- let loadGpuLayers = stateBeforeUnload.gpuLayers;
- let loadNCpuMoe = stateBeforeUnload.nCpuMoe;
+ const loadGpuMemoryMode =
+ pendingLoadConfig?.gpuMemoryMode ?? stateBeforeUnload.gpuMemoryMode;
+ let loadGpuLayers =
+ pendingLoadConfig?.gpuLayers ?? stateBeforeUnload.gpuLayers;
+ let loadNCpuMoe =
+ pendingLoadConfig?.nCpuMoe ?? stateBeforeUnload.nCpuMoe;
let loadSplitRatio = stateBeforeUnload.splitRatio;
// Reconcile the persisted pick against the GPUs present now, so a stale
// cross-host / now-hidden pick is dropped before /load rather than
// rejected there. Warm the device cache first: load-on-selection can
// run before any GPU hook mounted, and a cold cache would pass the
// pick through unvalidated. validateGpuIds derives from this too.
- if (stateBeforeUnload.selectedGpuIds != null) {
+ if (
+ pendingLoadConfig?.selectedGpuIds !== undefined ||
+ stateBeforeUnload.selectedGpuIds != null
+ ) {
await ensureGpuDeviceCache();
}
- let loadSelectedGpuIds = reconcilePersistedGpuIds(
- stateBeforeUnload.selectedGpuIds,
- );
- let loadSpeculativeType = stateBeforeUnload.speculativeType;
- let loadSpecDraftNMax = stateBeforeUnload.specDraftNMax;
+ let loadSelectedGpuIds =
+ pendingLoadConfig?.selectedGpuIds !== undefined
+ ? reconcilePersistedGpuIds(pendingLoadConfig.selectedGpuIds)
+ : reconcilePersistedGpuIds(stateBeforeUnload.selectedGpuIds);
+ let loadSpeculativeType =
+ pendingLoadConfig?.speculativeType != null
+ ? normalizeSpeculativeType(pendingLoadConfig.speculativeType)
+ : stateBeforeUnload.speculativeType;
+ let loadSpecDraftNMax =
+ pendingLoadConfig?.specDraftNMax ?? stateBeforeUnload.specDraftNMax;
try {
// Lightweight pre-flight validation: avoid unloading a working model
// if the new identifier is clearly invalid (e.g. bad HF id / path).
@@ -810,15 +838,23 @@ export function useChatModelRuntime() {
// model loads at Auto/native, not the previous model's pin.
customContextLength: null,
});
- loadSpeculativeType = persistedSpeculativeType;
- loadSpecDraftNMax = null;
+ loadSpeculativeType =
+ pendingLoadConfig?.speculativeType != null
+ ? normalizeSpeculativeType(pendingLoadConfig.speculativeType)
+ : persistedSpeculativeType;
+ loadSpecDraftNMax = pendingLoadConfig?.specDraftNMax ?? null;
// Keep the click-time snapshot in lock-step with the store reset so
// the load below sizes against the cleared per-model knobs, not the
// previous model's (gpuMemoryMode is standing, so left as captured).
- loadCustomContextLength = null;
- loadSelectedGpuIds = null;
- loadGpuLayers = GPU_LAYERS_AUTO;
- loadNCpuMoe = 0;
+ // An explicit staged config from run-settings still wins.
+ loadCustomContextLength =
+ pendingLoadConfig?.customContextLength ?? null;
+ loadSelectedGpuIds =
+ pendingLoadConfig?.selectedGpuIds !== undefined
+ ? reconcilePersistedGpuIds(pendingLoadConfig.selectedGpuIds)
+ : null;
+ loadGpuLayers = pendingLoadConfig?.gpuLayers ?? GPU_LAYERS_AUTO;
+ loadNCpuMoe = pendingLoadConfig?.nCpuMoe ?? 0;
loadSplitRatio = null;
}
@@ -1271,12 +1307,19 @@ export function useChatModelRuntime() {
prog.expected_bytes,
dlSamples,
);
- setLoadProgress({
- percent: pct,
- label: progressLabel,
- phase: "downloading",
- });
- if (loadToastDismissedRef.current) return;
+ // loadProgress state is only read by the dismissed-toast inline
+ // status. Writing it while the toast is visible re-renders the
+ // whole chat page every poll — cheap in Chrome, janky in the
+ // desktop WebView2 (laggy typing). Feed the toast directly and
+ // only touch state when the inline view is actually live.
+ if (loadToastDismissedRef.current) {
+ setLoadProgress({
+ percent: pct,
+ label: progressLabel,
+ phase: "downloading",
+ });
+ return;
+ }
toast(null, {
id: toastId,
...modelLoadToastOptions(
@@ -1298,19 +1341,23 @@ export function useChatModelRuntime() {
const est = estimate(dlSamples, prog.downloaded_bytes, 0);
const rateSuffix =
est.stable ? ` • ${formatRate(est.rate)}` : "";
- setLoadProgress({
- percent: null,
- label: `${dlGb.toFixed(1)} GB downloaded${rateSuffix}`,
- phase: "downloading",
- });
+ // Inline-status-only state; skip the chat-page re-render unless it's shown.
+ if (loadToastDismissedRef.current) {
+ setLoadProgress({
+ percent: null,
+ label: `${dlGb.toFixed(1)} GB downloaded${rateSuffix}`,
+ phase: "downloading",
+ });
+ }
} else if (prog.progress >= 1 && hasShownProgress) {
downloadComplete = true;
- setLoadProgress({
- percent: 100,
- label: "Download complete",
- phase: "starting",
- });
- if (!loadToastDismissedRef.current) {
+ if (loadToastDismissedRef.current) {
+ setLoadProgress({
+ percent: 100,
+ label: "Download complete",
+ phase: "starting",
+ });
+ } else {
toast(null, {
id: toastId,
...modelLoadToastOptions(
@@ -1364,12 +1411,17 @@ export function useChatModelRuntime() {
formatEta(est.eta) !== "--" ? ` • ${formatEta(est.eta)} left` : ""
}`
: base;
- setLoadProgress({
- percent: pct,
- label,
- phase: "starting",
- });
- if (loadToastDismissedRef.current) return;
+ // Inline-status-only state (see pollDownload): while the toast is
+ // up, skip the state write so the chat page doesn't re-render every
+ // poll during "Starting model" — the desktop WebView2 typing-lag fix.
+ if (loadToastDismissedRef.current) {
+ setLoadProgress({
+ percent: pct,
+ label,
+ phase: "starting",
+ });
+ return;
+ }
toast(null, {
id: toastId,
...modelLoadToastOptions(
diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx
index afbd33af7c..90202a2bcf 100644
--- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx
+++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx
@@ -24,7 +24,14 @@ import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { toast } from "@/lib/toast";
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { type ReactNode, useEffect, useId, useState } from "react";
+import {
+ type ReactNode,
+ type Ref,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+} from "react";
import {
useDefaultChatTemplate,
useModelMaxPositionEmbeddings,
@@ -50,7 +57,10 @@ import {
} from "../model-config/per-model-config";
import { ChatTemplateEditorDialog } from "./chat-template-editor-dialog";
import type { ModelPickTarget } from "./model-selector/types";
-import { NumericValueInput } from "./numeric-value-input";
+import {
+ NumericValueInput,
+ type NumericValueInputHandle,
+} from "./numeric-value-input";
const ROW_CLASS = "flex min-h-8 items-center justify-between gap-3";
const LABEL_CLASS =
@@ -130,11 +140,13 @@ function MaxSeqLengthSetting({
max,
inputMax,
onChange,
+ inputRef,
}: {
value: number;
max: number;
inputMax: number;
onChange: (value: number) => void;
+ inputRef?: Ref;
}) {
return (
@@ -146,6 +158,7 @@ function MaxSeqLengthSetting({
void;
displayValue?: string;
info?: ReactNode;
+ inputRef?: Ref;
}) {
return (
@@ -199,6 +214,7 @@ function AdvancedGpuSlider({
{info && {info}}
) => void;
layerCount: number | null;
moeLayerCount: number | null;
+ gpuLayersInputRef?: Ref;
+ moeLayersInputRef?: Ref;
}) {
const gpuDevices = useGpuDevices();
const mode = config.gpuMemoryMode ?? "auto";
@@ -322,6 +342,7 @@ function GpuMemorySettings({
<>
) => void;
@@ -407,6 +431,8 @@ function GgufAdvancedSettings({
onEditTemplate: () => void;
layerCount: number | null;
moeLayerCount: number | null;
+ gpuLayersInputRef?: Ref;
+ moeLayersInputRef?: Ref;
}) {
return (
<>
@@ -535,6 +561,8 @@ function GgufAdvancedSettings({
update={update}
layerCount={layerCount}
moeLayerCount={moeLayerCount}
+ gpuLayersInputRef={gpuLayersInputRef}
+ moeLayersInputRef={moeLayersInputRef}
/>
@@ -597,6 +625,10 @@ export function ModelConfigPage({
const [showAdvanced, setShowAdvanced] = useState(() =>
hasNonDefaultAdvanced(config),
);
+ const contextInputRef = useRef(null);
+ const maxSeqLengthInputRef = useRef(null);
+ const gpuLayersInputRef = useRef(null);
+ const moeLayersInputRef = useRef(null);
const nativePathToken =
target.meta.nativePathToken ??
(isActiveModel ? activeNativePathToken : null);
@@ -744,11 +776,6 @@ export function ModelConfigPage({
? { ...config, customContextLength: activeLoadedContext }
: config
: config;
- // Load request needs a concrete max length; substitute the fallback here only,
- // never in the persisted runtimeConfig.
- const loadConfig = target.isGguf
- ? runtimeConfig
- : { ...runtimeConfig, maxSeqLength: maxSeqLengthValue };
const rememberChanged = remember !== savedRemember;
const persistenceOnly = isActiveModel && atBaseline && rememberChanged;
const primaryActionLabel = persistenceOnly
@@ -760,18 +787,90 @@ export function ModelConfigPage({
: "Load model";
const handleRun = () => {
- const defaultConfig = isDefaultConfig(runtimeConfig);
+ // Same-click Load/Reload: a numeric draft the user just typed is flushed only
+ // by that input's blur handler, which updates the parent config after this
+ // click closure already captured the stale value. Commit every numeric input
+ // imperatively so the staged load honors what the user just typed, not just
+ // the Context field.
+ const committedContext = target.isGguf
+ ? contextInputRef.current?.commit()
+ : undefined;
+ const committedMaxSeqLength = target.isGguf
+ ? undefined
+ : maxSeqLengthInputRef.current?.commit();
+ const committedGpuLayers = target.isGguf
+ ? gpuLayersInputRef.current?.commit()
+ : undefined;
+ const committedMoeLayers = target.isGguf
+ ? moeLayersInputRef.current?.commit()
+ : undefined;
+
+ const pendingPatch: Partial = {};
+ if (committedContext != null) {
+ pendingPatch.customContextLength = committedContext;
+ }
+ if (committedMaxSeqLength != null) {
+ pendingPatch.maxSeqLength = clampMaxSeqLength(
+ committedMaxSeqLength,
+ MAX_SEQ_LENGTH_MAX,
+ );
+ }
+ if (committedGpuLayers != null) {
+ pendingPatch.gpuLayers = committedGpuLayers;
+ }
+ if (committedMoeLayers != null) {
+ pendingPatch.nCpuMoe = committedMoeLayers;
+ }
+ const hasPending =
+ committedContext != null ||
+ committedMaxSeqLength != null ||
+ committedGpuLayers != null ||
+ committedMoeLayers != null;
+
+ const effectiveConfig = hasPending
+ ? { ...config, ...pendingPatch }
+ : config;
+ // pinFixedLayerContext above was computed from the render-time config, before
+ // the same-click GPU Layers draft was committed. Recompute it from
+ // effectiveConfig so committing a positive fixed-layer value still pins the
+ // fitted context; otherwise the saved config carries customContextLength: null
+ // and a later fresh load sends the native context with fixed layers (the OOM
+ // the pin exists to avoid).
+ const effectivePinFixedLayerContext =
+ target.isGguf &&
+ effectiveConfig.gpuMemoryMode === "manual" &&
+ effectiveConfig.gpuLayers != null &&
+ effectiveConfig.gpuLayers >= 0 &&
+ effectiveConfig.customContextLength == null &&
+ activeLoadedContext != null;
+ const effectiveRuntimeConfig = hasPending
+ ? effectivePinFixedLayerContext
+ ? { ...effectiveConfig, customContextLength: activeLoadedContext }
+ : effectiveConfig
+ : runtimeConfig;
+ // Non-GGUF load substitutes the resolved max sequence length; recompute it
+ // from the committed draft so a same-click Max Seq Length edit is not lost.
+ const effectiveMaxSeqLengthValue =
+ committedMaxSeqLength == null
+ ? maxSeqLengthValue
+ : (normalizeMaxSeqLength(effectiveConfig.maxSeqLength) ??
+ clampMaxSeqLength(DEFAULT_MAX_SEQ_LENGTH, nativeMaxSeqLength));
+ // Recheck the committed draft so Save/Forget reloads when needed.
+ const effectiveAtBaseline = perModelConfigsEqual(effectiveConfig, baseline);
+ const effectivePersistenceOnly =
+ isActiveModel && effectiveAtBaseline && rememberChanged;
+ const defaultConfig = isDefaultConfig(effectiveRuntimeConfig);
let saveFailed = false;
if (remember) {
saveFailed = !savePerModelConfig(
target.id,
target.ggufVariant,
- runtimeConfig,
+ effectiveRuntimeConfig,
);
} else {
saveFailed = !deletePerModelConfig(target.id, target.ggufVariant);
}
- if (persistenceOnly) {
+ if (effectivePersistenceOnly) {
if (saveFailed) {
toast.error("Couldn't save settings for this model.");
return;
@@ -791,7 +890,10 @@ export function ModelConfigPage({
if (saveFailed) {
toast.error("Couldn't save these settings, loading with them anyway.");
}
- onRun(loadConfig);
+ const effectiveLoadConfig = target.isGguf
+ ? effectiveRuntimeConfig
+ : { ...effectiveRuntimeConfig, maxSeqLength: effectiveMaxSeqLengthValue };
+ onRun(effectiveLoadConfig);
};
return (
@@ -838,6 +940,7 @@ export function ModelConfigPage({
setTemplateOpen(true)}
layerCount={stagedDims?.layerCount ?? null}
moeLayerCount={stagedDims?.moeLayerCount ?? null}
+ gpuLayersInputRef={gpuLayersInputRef}
+ moeLayersInputRef={moeLayersInputRef}
/>
)}
@@ -914,6 +1019,7 @@ export function ModelConfigPage({
value={maxSeqLengthValue}
max={maxSeqLengthMax}
inputMax={MAX_SEQ_LENGTH_MAX}
+ inputRef={maxSeqLengthInputRef}
onChange={(value) =>
update({
maxSeqLength: clampMaxSeqLength(value, MAX_SEQ_LENGTH_MAX),
diff --git a/studio/frontend/src/features/model-picker/components/numeric-value-input.tsx b/studio/frontend/src/features/model-picker/components/numeric-value-input.tsx
index 2489927fc2..be9aa7745c 100644
--- a/studio/frontend/src/features/model-picker/components/numeric-value-input.tsx
+++ b/studio/frontend/src/features/model-picker/components/numeric-value-input.tsx
@@ -2,7 +2,13 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
-import { useRef, useState } from "react";
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from "react";
export function snapToStep(
value: number,
@@ -28,44 +34,109 @@ function sanitizeNumeric(raw: string, allowNegative: boolean): string {
return `${sign}${head}${tail}`;
}
-export function NumericValueInput({
- value,
- min,
- max,
- step,
- onChange,
- displayValue,
- className,
- ariaLabel,
- size: sizeAttr,
- disabled = false,
-}: {
- value: number;
- min?: number;
- max?: number;
- step: number;
- onChange: (v: number) => void;
- displayValue?: string;
- className?: string;
- ariaLabel?: string;
- size?: number;
- disabled?: boolean;
-}) {
+export type NumericValueInputHandle = {
+ /** Commit a valid focused/same-click draft; null when none is pending. */
+ commit: () => number | null;
+};
+
+export const NumericValueInput = forwardRef<
+ NumericValueInputHandle,
+ {
+ value: number;
+ min?: number;
+ max?: number;
+ step: number;
+ onChange: (v: number) => void;
+ displayValue?: string;
+ className?: string;
+ ariaLabel?: string;
+ size?: number;
+ disabled?: boolean;
+ }
+>(function NumericValueInput(
+ {
+ value,
+ min,
+ max,
+ step,
+ onChange,
+ displayValue,
+ className,
+ ariaLabel,
+ size: sizeAttr,
+ disabled = false,
+ },
+ ref,
+) {
const [focused, setFocused] = useState(false);
const [draft, setDraft] = useState("");
const cancelBlurCommitRef = useRef(false);
+ const draftRef = useRef("");
+ const dirtyRef = useRef(false);
+ // Same-click Load: blur commits via onChange and clears dirtyRef before the
+ // button onClick runs, while parent `value` is still stale. Keep the blur
+ // result for one imperative commit(); clear when `value` catches up or on
+ // focus / external edits (Reset, slider).
+ const lastBlurCommittedRef = useRef(null);
- const commit = (raw: string) => {
+ // The blur bridge is only valid across the single synchronous gesture that set
+ // it: blur commits during a button's mousedown and that button's onClick
+ // consumes it via commit() before React re-renders. Any settled render means the
+ // gesture is over, so drop the cache on every commit. Keying this on [value]
+ // alone missed a Reset (or other external edit) that restores the shown value
+ // unchanged when the blur did dispatch onChange (final !== value): value nets
+ // back to its prior number, so the effect never re-ran, the stale pin survived,
+ // and the next Load/Save replayed the override Reset had removed.
+ useEffect(() => {
+ lastBlurCommittedRef.current = null;
+ });
+
+ const commitDraft = (raw: string): number | null => {
const parsed = Number.parseFloat(raw);
if (!Number.isFinite(parsed)) {
- return;
+ return null;
}
const final = snapToStep(parsed, step, min, max);
if (final !== value) {
onChange(final);
}
+ return final;
};
+ useImperativeHandle(
+ ref,
+ () => ({
+ commit: () => {
+ if (dirtyRef.current) {
+ const raw = draftRef.current;
+ const final = commitDraft(raw);
+ dirtyRef.current = false;
+ lastBlurCommittedRef.current = null;
+ if (final == null) {
+ draftRef.current = String(value);
+ }
+ if (focused) {
+ setFocused(false);
+ }
+ return final;
+ }
+ const blurCommitted = lastBlurCommittedRef.current;
+ if (blurCommitted != null) {
+ lastBlurCommittedRef.current = null;
+ if (focused) {
+ setFocused(false);
+ }
+ return blurCommitted;
+ }
+ if (focused) {
+ setFocused(false);
+ }
+ return null;
+ },
+ }),
+ [draft, focused, max, min, onChange, step, value],
+ );
+
const displayed = focused ? draft : (displayValue ?? String(value));
return (
@@ -82,7 +153,11 @@ export function NumericValueInput({
aria-label={ariaLabel}
onFocus={(e) => {
cancelBlurCommitRef.current = false;
- setDraft(String(value));
+ dirtyRef.current = false;
+ lastBlurCommittedRef.current = null;
+ const next = String(value);
+ draftRef.current = next;
+ setDraft(next);
setFocused(true);
const target = e.currentTarget;
requestAnimationFrame(() => target.select());
@@ -90,24 +165,47 @@ export function NumericValueInput({
onBlur={() => {
if (cancelBlurCommitRef.current) {
cancelBlurCommitRef.current = false;
- } else {
- commit(draft);
+ lastBlurCommittedRef.current = null;
+ } else if (dirtyRef.current) {
+ const final = commitDraft(draftRef.current);
+ dirtyRef.current = false;
+ if (final == null) {
+ draftRef.current = String(value);
+ lastBlurCommittedRef.current = null;
+ } else {
+ draftRef.current = String(final);
+ // Only bridge the still-stale parent value when the blur actually
+ // dispatched onChange (final !== value). When final === value the
+ // parent is already current, so there is nothing to bridge; caching
+ // here would leave a stale pin that a later Reset or external edit
+ // (which doesn't change the displayed value) can never clear, so a
+ // following Load/Save would recreate the override Reset removed.
+ lastBlurCommittedRef.current = final !== value ? final : null;
+ }
}
setFocused(false);
}}
- onChange={(e) =>
- setDraft(sanitizeNumeric(e.target.value, (min ?? 0) < 0))
- }
+ onChange={(e) => {
+ dirtyRef.current = true;
+ lastBlurCommittedRef.current = null;
+ const next = sanitizeNumeric(e.target.value, (min ?? 0) < 0);
+ draftRef.current = next;
+ setDraft(next);
+ }}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelBlurCommitRef.current = true;
- setDraft(String(value));
+ dirtyRef.current = false;
+ lastBlurCommittedRef.current = null;
+ const next = String(value);
+ draftRef.current = next;
+ setDraft(next);
e.currentTarget.blur();
}
}}
className={cn(className)}
/>
);
-}
+});
diff --git a/studio/frontend/src/features/model-picker/index.ts b/studio/frontend/src/features/model-picker/index.ts
index d2b4785ec3..383d441f09 100644
--- a/studio/frontend/src/features/model-picker/index.ts
+++ b/studio/frontend/src/features/model-picker/index.ts
@@ -12,6 +12,7 @@ export {
export { hfModelFitsDevice } from "./components/model-selector/recommended-fit";
export {
NumericValueInput,
+ type NumericValueInputHandle,
snapToStep,
} from "./components/numeric-value-input";
export { SidebarModelConfig } from "./components/sidebar-model-config";
diff --git a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx
index 0a0eff849b..e777826315 100644
--- a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx
@@ -1,10 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-import { type ReactElement, useCallback } from "react";
-import { Lock, LockOpen, Maximize2, Minus, Plus } from "lucide-react";
-import { Panel, useReactFlow } from "@xyflow/react";
import { Button } from "@/components/ui/button";
+import { Panel, useReactFlow } from "@xyflow/react";
+import {
+ Focus,
+ Lock,
+ LockOpen,
+ Maximize2,
+ Minimize2,
+ Minus,
+ Plus,
+} from "lucide-react";
+import { type ReactElement, useCallback } from "react";
import { buildFitViewOptions } from "../../utils/graph/fit-view";
import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "../recipe-floating-icon-button-class";
@@ -12,12 +20,16 @@ type ViewportControlsProps = {
interactive: boolean;
lockDisabled?: boolean;
onToggleInteractive: () => void;
+ maximized: boolean;
+ onToggleMaximize: () => void;
};
export function ViewportControls({
interactive,
lockDisabled = false,
onToggleInteractive,
+ maximized,
+ onToggleMaximize,
}: ViewportControlsProps): ReactElement {
const { zoomIn, zoomOut, fitView, getNodes } = useReactFlow();
@@ -61,9 +73,23 @@ export function ViewportControls({
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleFitView}
- aria-label="Fit view"
+ aria-label="Center view"
>
-
+
+
+
);
diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
index 30302b86a7..f1f5e0958e 100644
--- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
+++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
@@ -237,6 +237,7 @@ export function RecipeStudioPage({
}, [setActiveView]);
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
+ const [maximized, setMaximized] = useState(false);
const [runtimeIslandMinimized, setRuntimeIslandMinimized] = useState(false);
const [recentCompletedExecution, setRecentCompletedExecution] =
useState(null);
@@ -569,6 +570,16 @@ export function RecipeStudioPage({
[reactFlowInstance],
);
+ const toggleMaximize = useCallback(() => {
+ // The maximized surface is a fixed z-50 overlay that already covers the
+ // app sidebar (z-10/z-20), so we don't touch the sidebar's own state — that
+ // state is persisted in pin mode and mutating it here would leak the
+ // temporary collapse into the next page/session.
+ setMaximized((prev) => !prev);
+ // Container size changes; refit once the layout settles.
+ scheduleFitView({ delayMs: TAB_SWITCH_FIT_DELAY_MS });
+ }, [scheduleFitView]);
+
useEffect(() => {
if (
previousActiveViewRef.current !== activeView &&
@@ -587,6 +598,15 @@ export function RecipeStudioPage({
}
}, [activeView, reactFlowInstance]);
+ // The "Exit full view" control lives inside the editor canvas, which unmounts
+ // on other tabs. Drop full-view mode when leaving the editor so Easy/Runs
+ // aren't left under the fixed overlay.
+ useEffect(() => {
+ if (activeView !== "editor" && maximized) {
+ setMaximized(false);
+ }
+ }, [activeView, maximized]);
+
useEffect(() => {
if (
!reactFlowInstance ||
@@ -732,6 +752,8 @@ export function RecipeStudioPage({
interactive={canvasInteractive}
lockDisabled={executionLocked}
onToggleInteractive={toggleInteractive}
+ maximized={maximized}
+ onToggleMaximize={toggleMaximize}
/>
{islandExecution &&
(isExecutionInProgress(islandExecution.status) ||
@@ -773,10 +795,25 @@ export function RecipeStudioPage({
}
return (
-
-
+
+
{activeView === "easy" ? (
diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css
index ba112d5f53..4797401ead 100644
--- a/studio/frontend/src/index.css
+++ b/studio/frontend/src/index.css
@@ -282,8 +282,13 @@
/* Standard interactive-icon size for nav, menus, action bars, and
in-message code-block actions. Sized one step above body text so
icons read as minimally larger than adjacent labels (~14px text).
+ Follows the UI font size preference; 18px at the default.
Theme-independent — declared once in :root. */
- --icon-size: 18px;
+ /* Standard icon size follows the UI font size itself: matches it below
+ the 16px default, grows at half the change above it (setting 20 ->
+ 18px), so icons read slightly smaller than enlarged text. */
+ --ui-icon-size: min(calc(1rem * var(--ui-font-scale, 1)), calc(0.5rem + 0.5rem * var(--ui-font-scale, 1)));
+ --icon-size: var(--ui-icon-size);
/* Inset of a centered .size-icon glyph within a 2rem (size-8) action
button — i.e. (32px − icon-size) / 2. Use as a negative margin on a
chat-message action bar so the leftmost icon's visual edge aligns
@@ -1265,8 +1270,8 @@ html[data-chat-font] .aui-root {
}
.app-user-menu [data-slot="dropdown-menu-item"] svg,
.app-user-menu [data-slot="dropdown-menu-sub-trigger"] svg {
- width: 19px !important;
- height: 19px !important;
+ width: var(--ui-icon-size) !important;
+ height: var(--ui-icon-size) !important;
flex-shrink: 0;
}
.app-user-menu [data-slot="dropdown-menu-item"]:focus,
@@ -1561,20 +1566,20 @@ html[data-chat-font] .aui-root {
/* Fixed-width icon slot so every pill's icon occupies the same space and
the labels line up on an even rhythm, regardless of icon size. */
.composer-pill-glyph {
- @apply relative inline-flex w-[19px] shrink-0 items-center justify-center transition-opacity;
+ @apply relative inline-flex w-[var(--ui-icon-size)] shrink-0 items-center justify-center transition-opacity;
}
/* On hover the icon swaps for an X inside a soft circle (ChatGPT-style),
filling the icon slot so every pill's X is identical and centered. */
.composer-pill-x {
- @apply pointer-events-none absolute inset-0 m-auto size-[19px] rounded-full bg-primary/15 p-[3px] opacity-0 transition-opacity dark:bg-white/[0.14];
+ @apply pointer-events-none absolute inset-0 m-auto size-[var(--ui-icon-size)] rounded-full bg-primary/15 p-[3px] opacity-0 transition-opacity dark:bg-white/[0.14];
}
/* Icon-only (compact) pills are too small for the circle, so show a bare x. */
[data-pill-compact="true"]
.composer-pill-btn:not([data-keep-label])
.composer-pill-x {
- @apply size-[15px] bg-transparent p-0 dark:bg-transparent;
+ @apply size-[min(calc(15px*var(--ui-font-scale,1)),calc(7.5px+7.5px*var(--ui-font-scale,1)))] bg-transparent p-0 dark:bg-transparent;
}
/* Compact pills hide their labels, so surface the name as a hover
@@ -1853,8 +1858,8 @@ html[data-chat-font] .aui-root {
/* Smaller tick for selected Thinking options. */
.unsloth-tick {
- width: 0.8rem !important;
- height: 0.8rem !important;
+ width: min(calc(0.8rem * var(--ui-font-scale, 1)), calc(0.4rem + 0.4rem * var(--ui-font-scale, 1))) !important;
+ height: min(calc(0.8rem * var(--ui-font-scale, 1)), calc(0.4rem + 0.4rem * var(--ui-font-scale, 1))) !important;
}
/* Soft elevation; [data-slot] outranks the component ring-1, dropping the border. */
@@ -1943,9 +1948,9 @@ html[data-chat-font] .aui-root {
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-sub-trigger"]
)
- svg {
- width: 1.15rem;
- height: 1.15rem;
+ svg:not(.unsloth-tick) {
+ width: var(--ui-icon-size) !important;
+ height: var(--ui-icon-size) !important;
}
/* Destructive items keep red text and a red-tinted hover, not the grey one. */
@@ -2770,3 +2775,96 @@ html[data-chat-font] .aui-root {
display: block !important;
width: 8px;
}
+
+/* Icons that sit beside scaled labels follow the UI font size itself:
+ glyphs at or above a 16px base render at --ui-icon-size (12 -> 12px,
+ 16 -> 16px, 20 -> 18px), so icons track the text below the default and
+ read slightly smaller than it above. Sub-16px glyphs keep their
+ proportions through the same curve as a factor. Menu, select and closed select trigger surfaces, popovers, toasts,
+ the chat thread and both composers. Only glyphs scale; hit targets,
+ paddings and surface geometry stay fixed. Identity at the default. */
+:is(
+ [data-slot='dropdown-menu-content'],
+ [data-slot='dropdown-menu-sub-content'],
+ [data-slot='select-content'],
+ [data-slot='select-trigger'],
+ [data-slot='combobox-content'],
+ [data-slot='combobox-trigger'],
+ [data-slot='context-menu-content'],
+ [data-slot='context-menu-sub-content'],
+ [data-slot='menubar-content'],
+ [data-slot='popover-content'],
+ [data-slot='command'],
+ [data-sonner-toast],
+ .composer-action-wrapper,
+ .aui-composer-action-wrapper,
+ .aui-action-bar-more-content,
+ .aui-root
+) {
+ & svg.size-2\.5 { width: min(calc(0.625rem * var(--ui-font-scale, 1)), calc(0.3125rem + 0.3125rem * var(--ui-font-scale, 1))); height: min(calc(0.625rem * var(--ui-font-scale, 1)), calc(0.3125rem + 0.3125rem * var(--ui-font-scale, 1))); }
+ & svg.size-3 { width: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); height: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
+ & svg.size-3\.5 { width: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); height: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); }
+ & svg.size-4 { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-4\.5 { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-5 { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-6 { width: min(calc(1.5rem * var(--ui-font-scale, 1)), calc(0.75rem + 0.75rem * var(--ui-font-scale, 1))); height: min(calc(1.5rem * var(--ui-font-scale, 1)), calc(0.75rem + 0.75rem * var(--ui-font-scale, 1))); }
+ & svg.size-\[5px\] { width: min(calc(5px * var(--ui-font-scale, 1)), calc(2.5px + 2.5px * var(--ui-font-scale, 1))); height: min(calc(5px * var(--ui-font-scale, 1)), calc(2.5px + 2.5px * var(--ui-font-scale, 1))); }
+ & svg.size-\[6px\] { width: min(calc(6px * var(--ui-font-scale, 1)), calc(3px + 3px * var(--ui-font-scale, 1))); height: min(calc(6px * var(--ui-font-scale, 1)), calc(3px + 3px * var(--ui-font-scale, 1))); }
+ & svg.size-\[10px\] { width: min(calc(10px * var(--ui-font-scale, 1)), calc(5px + 5px * var(--ui-font-scale, 1))); height: min(calc(10px * var(--ui-font-scale, 1)), calc(5px + 5px * var(--ui-font-scale, 1))); }
+ & svg.size-\[11px\] { width: min(calc(11px * var(--ui-font-scale, 1)), calc(5.5px + 5.5px * var(--ui-font-scale, 1))); height: min(calc(11px * var(--ui-font-scale, 1)), calc(5.5px + 5.5px * var(--ui-font-scale, 1))); }
+ & svg.size-\[12px\] { width: min(calc(12px * var(--ui-font-scale, 1)), calc(6px + 6px * var(--ui-font-scale, 1))); height: min(calc(12px * var(--ui-font-scale, 1)), calc(6px + 6px * var(--ui-font-scale, 1))); }
+ & svg.size-\[13px\] { width: min(calc(13px * var(--ui-font-scale, 1)), calc(6.5px + 6.5px * var(--ui-font-scale, 1))); height: min(calc(13px * var(--ui-font-scale, 1)), calc(6.5px + 6.5px * var(--ui-font-scale, 1))); }
+ & svg.size-\[14px\] { width: min(calc(14px * var(--ui-font-scale, 1)), calc(7px + 7px * var(--ui-font-scale, 1))); height: min(calc(14px * var(--ui-font-scale, 1)), calc(7px + 7px * var(--ui-font-scale, 1))); }
+ & svg.size-\[15px\] { width: min(calc(15px * var(--ui-font-scale, 1)), calc(7.5px + 7.5px * var(--ui-font-scale, 1))); height: min(calc(15px * var(--ui-font-scale, 1)), calc(7.5px + 7.5px * var(--ui-font-scale, 1))); }
+ & svg.size-\[15\.5px\] { width: min(calc(15.5px * var(--ui-font-scale, 1)), calc(7.75px + 7.75px * var(--ui-font-scale, 1))); height: min(calc(15.5px * var(--ui-font-scale, 1)), calc(7.75px + 7.75px * var(--ui-font-scale, 1))); }
+ & svg.size-\[16px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-\[17px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-\[18px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-\[18\.5px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-\[20px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-\[21px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-\[22px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
+ & svg.size-\[36px\] { width: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); height: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); }
+ & svg.w-3 { width: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
+ & svg.h-3 { height: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
+ & svg.w-3\.5 { width: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); }
+ & svg.h-3\.5 { height: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); }
+ & svg.w-4 { width: var(--ui-icon-size); }
+ & svg.h-4 { height: var(--ui-icon-size); }
+ & svg.w-5 { width: var(--ui-icon-size); }
+ & svg.h-5 { height: var(--ui-icon-size); }
+ /* Buttons default un-classed icons to size-4 the same way. Sonner's
+ close button keeps its compact 12px glyph inside a fixed control. */
+ & button:not([class*=':size-3'], [data-close-button]) svg:not([class*='size-'], [class*='w-'], [class*='h-'], .unsloth-tick) {
+ width: var(--ui-icon-size);
+ height: var(--ui-icon-size);
+ }
+ /* Menu items default un-classed icons to size-4. */
+ & [data-slot*='item'] svg:not([class*='size-'], [class*='w-'], [class*='h-']) {
+ width: var(--ui-icon-size);
+ height: var(--ui-icon-size);
+ }
+}
+
+/* Sonner injects fixed 13px toast text and 12px action labels at runtime;
+ text follows the preference at full rate. Line heights are unitless so
+ they track automatically. */
+[data-sonner-toast][data-styled='true'] {
+ font-size: calc(13px * var(--ui-font-scale, 1)) !important;
+}
+[data-sonner-toast][data-styled='true'] [data-description] {
+ font-size: calc(13px * var(--ui-font-scale, 1)) !important;
+}
+[data-sonner-toast][data-styled='true'] [data-button] {
+ font-size: calc(12px * var(--ui-font-scale, 1)) !important;
+}
+/* Sonner's icon well is a fixed 16px box; track the glyph. */
+[data-sonner-toast][data-styled='true'] [data-icon] {
+ width: var(--ui-icon-size) !important;
+ height: var(--ui-icon-size) !important;
+}
+/* Defensive: the built-in loader is unused (a custom loading icon is always
+ passed) but keep its fixed --size on the scale in case that changes. */
+[data-sonner-toast] .sonner-loading-wrapper {
+ --size: var(--ui-icon-size) !important;
+}
diff --git a/studio/src-tauri/src/native_file_dialogs.rs b/studio/src-tauri/src/native_file_dialogs.rs
index d795eb020c..b2635e66d3 100644
--- a/studio/src-tauri/src/native_file_dialogs.rs
+++ b/studio/src-tauri/src/native_file_dialogs.rs
@@ -46,10 +46,13 @@ fn save_filter(file_name: &str) -> (&'static str, Vec<&'static str>) {
Some("jsonl") | Some("ndjson") => ("JSON Lines", vec!["jsonl", "ndjson"]),
Some("csv") => ("CSV", vec!["csv"]),
Some("md") | Some("markdown") => ("Markdown", vec!["md", "markdown"]),
+ Some("html") | Some("htm") => ("HTML", vec!["html", "htm"]),
Some("zip") => ("ZIP archive", vec!["zip"]),
_ => (
"Export files",
- vec!["json", "jsonl", "ndjson", "csv", "md", "markdown", "zip"],
+ vec![
+ "json", "jsonl", "ndjson", "csv", "md", "markdown", "html", "htm", "zip",
+ ],
),
}
}
@@ -252,6 +255,12 @@ mod tests {
);
}
+ #[test]
+ fn html_canvas_exports_use_an_html_save_filter() {
+ assert_eq!(save_filter("canvas.html"), ("HTML", vec!["html", "htm"]));
+ assert_eq!(save_filter("canvas.HTM"), ("HTML", vec!["html", "htm"]));
+ }
+
#[test]
fn reads_supported_import_and_rejects_other_extensions() {
let jsonl_path = temp_path("allowed").with_extension("JSONL");
diff --git a/studio/src-tauri/tauri.conf.json b/studio/src-tauri/tauri.conf.json
index 51c4860e3c..e691ee565a 100644
--- a/studio/src-tauri/tauri.conf.json
+++ b/studio/src-tauri/tauri.conf.json
@@ -16,7 +16,7 @@
"app": {
"withGlobalTauri": true,
"security": {
- "csp": "default-src 'self'; connect-src 'self' http://localhost:* ws://localhost:* ws://127.0.0.1:* http://127.0.0.1:* https://huggingface.co https://*.huggingface.co https://datasets-server.huggingface.co; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:"
+ "csp": "default-src 'self'; connect-src 'self' http://localhost:* ws://localhost:* ws://127.0.0.1:* http://127.0.0.1:* https://huggingface.co https://*.huggingface.co https://datasets-server.huggingface.co; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' http://localhost:* http://127.0.0.1:*"
},
"windows": [
{
diff --git a/tests/studio/playwright_model_config.py b/tests/studio/playwright_model_config.py
index a8d143a253..d72dc37955 100644
--- a/tests/studio/playwright_model_config.py
+++ b/tests/studio/playwright_model_config.py
@@ -70,6 +70,8 @@ ART_DIR = os.environ.get("PW_ART_DIR", "logs/playwright_modelcfg")
ART = Path(ART_DIR)
ART.mkdir(parents = True, exist_ok = True)
STRICT = os.environ.get("STUDIO_UI_STRICT", "0") == "1"
+PLAYWRIGHT_BROWSER = os.environ.get("STUDIO_PLAYWRIGHT_BROWSER", "chromium").lower()
+PLAYWRIGHT_CHANNEL = os.environ.get("STUDIO_PLAYWRIGHT_CHANNEL") or None
TURN_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_TURN_TIMEOUT_MS", "180000"))
WALL_TIMEOUT_S = float(os.environ.get("STUDIO_UI_WALL_TIMEOUT_S", "720"))
FETCH_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_FETCH_TIMEOUT_MS", "30000"))
@@ -145,10 +147,19 @@ with sync_playwright() as p:
)
# Health pre-flight: bash-side health wait can pass before the auth DB migrates.
wait_for_health(BASE, timeout = 30.0, info = info)
- browser = p.chromium.launch(
- headless = True,
- args = chromium_launch_args(),
- )
+ if PLAYWRIGHT_BROWSER not in ("chromium", "firefox", "webkit"):
+ fail(f"unsupported STUDIO_PLAYWRIGHT_BROWSER={PLAYWRIGHT_BROWSER!r}")
+ sys.exit(1)
+ browser_type = getattr(p, PLAYWRIGHT_BROWSER)
+ launch_kwargs = {"headless": True}
+ if PLAYWRIGHT_BROWSER == "chromium":
+ launch_kwargs["args"] = chromium_launch_args()
+ if PLAYWRIGHT_CHANNEL:
+ launch_kwargs["channel"] = PLAYWRIGHT_CHANNEL
+ elif PLAYWRIGHT_CHANNEL:
+ fail("STUDIO_PLAYWRIGHT_CHANNEL requires chromium")
+ sys.exit(1)
+ browser = browser_type.launch(**launch_kwargs)
ctx = browser.new_context(
viewport = {"width": 1280, "height": 900},
reduced_motion = "reduce",
@@ -464,11 +475,6 @@ with sync_playwright() as p:
else:
default_ctx = ctx_in.input_value()
info(f"default Context Length shown: {default_ctx!r}")
- ctx_in.click()
- ctx_in.fill(str(DISTINCT_CTX))
- page.wait_for_timeout(300)
- page.keyboard.press("Tab") # blur to commit
- page.wait_for_timeout(300)
remember = popover.get_by_label("Remember for this model").first
if _count(remember):
try:
@@ -477,12 +483,16 @@ with sync_playwright() as p:
remember.click()
else:
fail("'Remember for this model' checkbox not found")
+ ctx_in.click()
+ ctx_in.fill(str(DISTINCT_CTX))
page.wait_for_timeout(300)
shoot("05-ctx-set")
btn = primary_button(popover)
if btn is None:
fail("primary Load/Save button not found in run-settings")
else:
+ # Keep the input focused. The button click must commit the draft
+ # and use it in the same load request.
btn.click()
page.wait_for_timeout(2500)
shoot("06-after-load")
@@ -570,6 +580,47 @@ with sync_playwright() as p:
else:
info("OK reset: distinctive context cleared from unsloth_model_configs")
shoot("08-after-reset")
+
+ # ─────────────────────────────────────────────────────
+ # 3b. Re-typing the value already shown must not pin an override (HARD).
+ # Entering the currently displayed native/default context commits no
+ # onChange (the value is unchanged), so the cached blur value must not be
+ # replayed into a stored override on Load. Otherwise re-typing the shown
+ # number, or doing so before a Reset, recreates a phantom context pin.
+ # ─────────────────────────────────────────────────────
+ step("re-typing the shown context does not pin an override")
+ ctx_in = context_input(popover)
+ native_default = _as_int(ctx_in.input_value()) if ctx_in else None
+ if ctx_in is None or native_default is None:
+ info("skip re-type-shown: Context Length input has no numeric default")
+ else:
+ remember = popover.get_by_label("Remember for this model").first
+ if _count(remember):
+ try:
+ remember.check()
+ except Exception:
+ remember.click()
+ ctx_in.click()
+ ctx_in.fill(str(native_default))
+ page.wait_for_timeout(200)
+ btn = primary_button(popover)
+ if btn is not None and btn.is_enabled():
+ # Same-click Load: the button click must commit the draft, but a draft
+ # equal to the shown value carries no override.
+ btn.click()
+ page.wait_for_timeout(1500)
+ cfg = read_configs()
+ pinned = any(
+ _as_int(e.get("customContextLength")) == native_default for e in config_entries(cfg)
+ )
+ if pinned:
+ fail(
+ "re-typing the shown context pinned it as an override "
+ f"(customContextLength={native_default})"
+ )
+ else:
+ info("OK re-type-shown: shown context not stored as an override")
+ shoot("08b-after-retype-shown")
close_picker()
# ─────────────────────────────────────────────────────
diff --git a/tests/studio/playwright_ui_font_scale.py b/tests/studio/playwright_ui_font_scale.py
index 0f14c42422..903d7745c1 100644
--- a/tests/studio/playwright_ui_font_scale.py
+++ b/tests/studio/playwright_ui_font_scale.py
@@ -208,6 +208,14 @@ def main():
# text-ui-12p5 at scale 0.75; 16px means twMerge dropped the token.
if not near(tab_font, 12.5 * 12 / 16):
fail(f"hub tab font did not scale (twMerge drop?): {tab_font}")
+ icon_w = page.evaluate(
+ "() => { const el = document.querySelector('.size-icon');"
+ " return el ? parseFloat(getComputedStyle(el).width) : null; }"
+ )
+ # Standard icons render at the UI font size itself below the
+ # default, so setting 12 gives 12px glyphs.
+ if not near(icon_w, 12):
+ fail(f"size-icon did not match the UI font size below 16: {icon_w}")
page.goto(BASE, wait_until = "domcontentloaded")
page.wait_for_timeout(1500)
open_appearance(page)
diff --git a/tests/studio/test_generation_length_ui_contract.py b/tests/studio/test_generation_length_ui_contract.py
new file mode 100644
index 0000000000..cc25f91080
--- /dev/null
+++ b/tests/studio/test_generation_length_ui_contract.py
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+
+from pathlib import Path
+
+
+CHAT_API = (
+ Path(__file__).resolve().parents[2]
+ / "studio"
+ / "frontend"
+ / "src"
+ / "features"
+ / "chat"
+ / "api"
+ / "chat-api.ts"
+)
+
+
+def test_length_detection_classifies_visible_and_reasoning_content():
+ source = CHAT_API.read_text(encoding = "utf-8")
+
+ assert "return value.trim().length > 0;" in source
+ assert 'record.type === "thinking" || record.type === "reasoning"' in source
+ assert 'record.type === "text" || record.type === "output_text"' in source
+ assert "sawAssistantContent ||= contentState.hasAssistantContent;" in source
+ assert "sawReasoningContent ||= contentState.hasReasoningContent;" in source
diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py
index 3934b72d83..815f68e010 100644
--- a/tests/studio/test_model_picker_contracts.py
+++ b/tests/studio/test_model_picker_contracts.py
@@ -346,6 +346,36 @@ def test_fixed_layer_gguf_pins_displayed_context():
assert "customContextLength: activeLoadedContext" in src
+def test_fixed_layer_pin_recomputed_after_committing_gpu_layers():
+ """pinFixedLayerContext is computed from the render-time config, before a
+ same-click GPU Layers draft is committed. handleRun must recompute it from the
+ committed effectiveConfig; otherwise typing a positive GPU Layers value on an
+ auto-fit GGUF and clicking Reload saves customContextLength: null, so a later
+ fresh load sends the native context with fixed layers (the OOM the pin avoids)."""
+ src = _read("features/model-picker/components/model-config-page.tsx")
+ assert "const effectivePinFixedLayerContext =" in src
+ assert 'effectiveConfig.gpuMemoryMode === "manual"' in src
+ assert "effectiveConfig.gpuLayers != null" in src
+ assert "effectiveConfig.customContextLength == null" in src
+ assert "{ ...effectiveConfig, customContextLength: activeLoadedContext }" in src
+
+
+def test_blur_cache_cleared_on_every_settled_render():
+ """The lastBlurCommittedRef bridge is valid only across the single synchronous
+ same-click gesture that set it. Keying its clear on [value] missed a Reset (or
+ external edit) that restores the shown value unchanged after the blur dispatched
+ onChange: value nets back to its prior number, the effect never re-ran, and a
+ later Load/Save replayed the override Reset removed. Clear it on every settled
+ render instead."""
+ src = _read("features/model-picker/components/numeric-value-input.tsx")
+ # The clearing effect must run on every commit, not be gated on [value] alone.
+ assert not re.search(r"lastBlurCommittedRef\.current = null;\s*\}, \[value\]\);", src)
+ assert re.search(
+ r"useEffect\(\(\) => \{\s*lastBlurCommittedRef\.current = null;\s*\}\);",
+ src,
+ )
+
+
def test_auto_defaults_not_persisted_as_overrides():
"""Auto GPU memory mode and Auto/default speculative type are follow-global
defaults; normalization must not persist them as per-model overrides, else a
@@ -382,15 +412,90 @@ def test_reset_persists_null_max_length_and_substitutes_only_for_load():
Reset) so isDefaultConfig can clear a remembered override; the concrete
fallback is substituted only into the load request, not the saved record."""
src = _read("features/model-picker/components/model-config-page.tsx")
- # Load-only substitution of the resolved value.
- assert "maxSeqLength: maxSeqLengthValue" in src
- assert "const loadConfig" in src
- # The persisted record is loaded via onRun(loadConfig), and save uses the
- # untouched runtimeConfig (so a reset/default config stays default).
- assert "onRun(loadConfig)" in src
+ # Load-only substitution of the resolved value (recomputed from any committed
+ # same-click Max Seq Length draft, so it is never dropped).
+ assert "maxSeqLength: effectiveMaxSeqLengthValue" in src
+ assert "const effectiveLoadConfig" in src
+ # The persisted record is saved from effectiveRuntimeConfig; the load request
+ # carries effectiveLoadConfig (with any committed context input).
+ assert "onRun(effectiveLoadConfig)" in src
assert "savePerModelConfig(" in src
+def test_initial_load_uses_staged_config_payload():
+ """Run-settings Load must pass the staged config through to /load even when
+ React has not flushed NumericValueInput blur commits into the store yet."""
+ runtime = _read("features/chat/hooks/use-chat-model-runtime.ts")
+ assert "const pendingLoadConfig =" in runtime
+ assert "pendingLoadConfig?.kvCacheDtype" in runtime
+ assert "pendingLoadConfig?.customContextLength" in runtime
+ page = _read("features/model-picker/components/model-config-page.tsx")
+ assert "contextInputRef" in page
+ assert "contextInputRef.current?.commit()" in page
+ numeric = _read("features/model-picker/components/numeric-value-input.tsx")
+ assert "export type NumericValueInputHandle" in numeric
+ assert "commit:" in numeric
+ # P1: commit returns null unless the user actually edited the field,
+ # so Load/Save with untouched Auto does not pin native context.
+ assert "dirtyRef.current" in numeric
+ assert "return null;" in numeric
+ # P2: blur clears dirtyRef after commit so Reset/slider cannot be
+ # overwritten by a stale draft on a later Load.
+ assert "dirtyRef.current = false;" in numeric
+ assert "draftRef.current = String(final);" in numeric
+ # Same-click Load after blur still sees the committed draft.
+ assert "lastBlurCommittedRef" in numeric
+ # Invalid drafts must not turn Auto into an explicit pin.
+ assert "const commitDraft = (raw: string): number | null" in numeric
+ assert re.search(r"if \(!Number\.isFinite\(parsed\)\) \{\s*return null;", numeric)
+ assert re.search(
+ r"if \(final == null\) \{\s*"
+ r"draftRef\.current = String\(value\);\s*"
+ r"lastBlurCommittedRef\.current = null;",
+ numeric,
+ )
+ # handleRun only promotes commit() when non-null.
+ assert "committedContext != null" in page
+ assert "pendingPatch.customContextLength = committedContext;" in page
+
+
+def test_same_click_commit_covers_all_numeric_inputs():
+ """The same-click blur bridge must flush every NumericValueInput-backed
+ setting, not just Context Length. Max Seq Length (non-GGUF), GPU Layers and
+ MoE Layers (GGUF) also stage their draft only on blur, so handleRun must
+ imperatively commit each and fold the value into the staged load config;
+ otherwise a value the user typed right before clicking Load/Reload is lost."""
+ page = _read("features/model-picker/components/model-config-page.tsx")
+ # Each numeric input owns an imperative handle that handleRun commits, and the
+ # handle is forwarded down to the actual NumericValueInput.
+ for ref in ("maxSeqLengthInputRef", "gpuLayersInputRef", "moeLayersInputRef"):
+ assert f"const {ref} = useRef(null);" in page
+ assert f"{ref}.current?.commit()" in page
+ assert f"inputRef={{{ref}}}" in page
+ # The leaf sub-components accept and forward the handle as a ref.
+ assert page.count("inputRef?: Ref;") >= 2
+ assert "ref={inputRef}" in page
+ # Committed drafts are folded into the staged config, gated on non-null so an
+ # untouched field never fabricates an override.
+ assert "committedMaxSeqLength != null" in page
+ assert "committedGpuLayers != null" in page
+ assert "committedMoeLayers != null" in page
+ assert "pendingPatch.gpuLayers = committedGpuLayers;" in page
+ assert "pendingPatch.nCpuMoe = committedMoeLayers;" in page
+ # The non-GGUF load path substitutes the committed Max Seq Length draft.
+ assert "const effectiveMaxSeqLengthValue =" in page
+ assert "maxSeqLength: effectiveMaxSeqLengthValue" in page
+
+
+def test_context_commit_rechecks_persistence_only_shortcut():
+ """Committed context changes must bypass persistence-only saves."""
+ src = _read("features/model-picker/components/model-config-page.tsx")
+ assert "const effectiveConfig =" in src
+ assert "perModelConfigsEqual(effectiveConfig, baseline)" in src
+ assert "const effectivePersistenceOnly =" in src
+ assert "if (effectivePersistenceOnly)" in src
+
+
def test_reset_enabled_for_explicit_context_pin_at_native():
"""An explicit customContextLength that equals the native ceiling is still a
user override, so contextAtDefault must require customContextLength == null.
diff --git a/tests/studio/test_ui_font_scale_contract.py b/tests/studio/test_ui_font_scale_contract.py
index 1153eea643..65d3215374 100644
--- a/tests/studio/test_ui_font_scale_contract.py
+++ b/tests/studio/test_ui_font_scale_contract.py
@@ -98,6 +98,39 @@ def test_cn_knows_the_ui_typography_tokens():
assert "/^ui-\\d+(p5)?$/.test(value)" in UTILS
+def test_icons_follow_the_ui_font_size_itself():
+ """Standard glyphs render at --ui-icon-size, which follows the UI font
+ size itself: matches it below the 16px default and grows at half the
+ change above it (setting 20 gives 18px icons), so icons track the text
+ when shrinking and read slightly smaller than it when growing. Sub 16px
+ glyphs keep their proportions through the same curve as a factor.
+ Sonner toast text and action labels are text, so they follow at full
+ rate everywhere."""
+ assert (
+ "--ui-icon-size: min(calc(1rem * var(--ui-font-scale, 1)), "
+ "calc(0.5rem + 0.5rem * var(--ui-font-scale, 1)));"
+ ) in INDEX_CSS
+ assert "--icon-size: var(--ui-icon-size);" in INDEX_CSS
+ assert "& svg.size-4 { width: var(--ui-icon-size); height: var(--ui-icon-size); }" in INDEX_CSS
+ assert "font-size: calc(13px * var(--ui-font-scale, 1)) !important;" in INDEX_CSS
+ assert "font-size: calc(12px * var(--ui-font-scale, 1)) !important;" in INDEX_CSS
+ # Menu rules that outrank the scoped block must carry the token too,
+ # without flattening the smaller thinking ticks.
+ assert "width: var(--ui-icon-size) !important;" in INDEX_CSS
+ assert "svg:not(.unsloth-tick) {" in INDEX_CSS
+ # Oversized art glyphs stay proportional instead of uniform.
+ assert "& svg.size-6 { width: min(calc(1.5rem" in INDEX_CSS
+ for scope in (
+ "[data-slot='dropdown-menu-content']",
+ "[data-slot='select-content']",
+ "[data-slot='select-trigger']",
+ "[data-slot='combobox-content']",
+ "[data-sonner-toast]",
+ ".aui-root",
+ ):
+ assert scope in INDEX_CSS
+
+
def test_no_raw_pixel_text_utilities():
offenders = []
for path in _frontend_sources():