diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 8088ca5f45..a9e123bab6 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -307,6 +307,26 @@ def _native_linux_system_rocm_lib_dirs(binary_dir: str = "") -> "list[str]": _DEFAULT_MAX_TOKENS_FLOOR = 32768 _DEFAULT_FIRST_TOKEN_TIMEOUT_S = 1200.0 # 20 min + +def _finalize_reasoning_only_cumulative( + cumulative: str, reasoning_text: str, finish_reason: Optional[str], promote_reasoning_only: bool +) -> str: + """Close a live thinking block and promote it only after a clean stop. + + Local inference streams cumulative snapshots. Replacing ``...`` with + bare reasoning at EOF makes the final snapshot shorter, so suffix-based + route consumers drop the intended fallback. Keep the snapshot append-only. + A length-truncated thought is not a final answer, so close it without + promotion and let the client surface the ``length`` terminal state. Raw + consumers that do not split reasoning from visible content can disable the + fallback to avoid returning the same reasoning twice. + """ + visible_fallback = ( + reasoning_text if promote_reasoning_only and finish_reason != "length" else "" + ) + return cumulative + "" + visible_fallback + + # Only large streamed tool payloads get an early provisional card; render_html # is exempt because it needs immediate artifact feedback. _PROVISIONAL_ARGS_MIN_CHARS = 256 @@ -10586,6 +10606,7 @@ class LlamaCppBackend: reasoning_effort: Optional[str] = None, preserve_thinking: Optional[bool] = None, seed: Optional[int] = None, + promote_reasoning_only: bool = True, _allow_respawn_retry: bool = True, ) -> Generator[Union[str, dict], None, None]: """ @@ -10668,7 +10689,12 @@ class LlamaCppBackend: # model put its whole reply in reasoning # (e.g. Qwen3 always-think). Show it as # the main response, not a thinking block. - cumulative = reasoning_text + cumulative = _finalize_reasoning_only_cumulative( + cumulative, + reasoning_text, + _metadata_finish_reason, + promote_reasoning_only, + ) yield cumulative _stream_done = True break # exit inner while @@ -10765,6 +10791,7 @@ class LlamaCppBackend: reasoning_effort = reasoning_effort, preserve_thinking = preserve_thinking, seed = seed, + promote_reasoning_only = promote_reasoning_only, _allow_respawn_retry = False, ) return @@ -10806,6 +10833,7 @@ class LlamaCppBackend: confirm_tool_calls: bool = False, bypass_permissions: bool = False, permission_mode: Optional[str] = None, + promote_reasoning_only: bool = True, ) -> Generator[dict, None, None]: """ Agentic loop: let the model call tools, execute them, and continue. @@ -11147,7 +11175,12 @@ class LlamaCppBackend: ), } else: - cumulative_display = reasoning_accum + cumulative_display = _finalize_reasoning_only_cumulative( + cumulative_display, + reasoning_accum, + _iter_finish_reason, + promote_reasoning_only, + ) if not _suppress_visible_output: yield { "type": "content", @@ -11611,7 +11644,12 @@ class LlamaCppBackend: if _reasoning_started_at is not None and not _reasoning_summary_emitted: _reasoning_summary_emitted = True yield _reasoning_summary_event(_reasoning_started_at) - cumulative_display = reasoning_accum + cumulative_display = _finalize_reasoning_only_cumulative( + cumulative_display, + reasoning_accum, + _iter_finish_reason, + promote_reasoning_only, + ) if not _suppress_visible_output: yield { "type": "content", @@ -12175,7 +12213,12 @@ class LlamaCppBackend: "text": _strip_tool_markup(cumulative, final = True), } else: - cumulative = reasoning_text + cumulative = _finalize_reasoning_only_cumulative( + cumulative, + reasoning_text, + _metadata_finish_reason, + promote_reasoning_only, + ) yield {"type": "content", "text": cumulative} _stream_done = True break # exit inner while diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 445a26f04d..9d40650a56 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1794,7 +1794,16 @@ router = APIRouter() studio_router = APIRouter() -_ARTIFACT_PREVIEW_FRAME_ANCESTORS = "'self' tauri://localhost http://tauri.localhost" +# Packaged desktop runs at tauri://localhost (macOS/Linux) or http://tauri.localhost +# (Windows WebView2); the web build is same-origin ('self'). The `tauri dev` shell, +# however, serves the frontend from the Vite dev origin (http://localhost:5173), +# so the packaged allowlist alone leaves the preview blocked in dev with an +# "ancestor violates frame-ancestors" error. This shell exposes no server resource +# (it only renders postMessage'd HTML in a no-same-origin sandbox), so also allowing +# any localhost/127.0.0.1 dev origin to frame it is safe and unblocks the dev shell. +_ARTIFACT_PREVIEW_FRAME_ANCESTORS = ( + "'self' tauri://localhost http://tauri.localhost http://localhost:* http://127.0.0.1:*" +) _ARTIFACT_PREVIEW_FRAME_STRICT_CSP = ( "default-src 'none'; " "script-src 'unsafe-inline'; " @@ -13355,6 +13364,7 @@ async def anthropic_messages( disable_parallel_tool_use = _disable_parallel, bypass_permissions = bool(payload.bypass_permissions), permission_mode = getattr(payload, "permission_mode", None), + promote_reasoning_only = False, ) if payload.stream: @@ -13394,6 +13404,7 @@ async def anthropic_messages( max_tokens = payload.max_tokens, stop = stop, cancel_event = cancel_event, + promote_reasoning_only = False, ) if payload.stream: diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 9ccc3f44dd..621ac9aaca 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -68,16 +68,15 @@ def _emitter_client_text(events: list[str]) -> str: def test_anthropic_emitter_closes_reasoning_only_think_block(): - # A reasoning-only reply streams X live then shrinks to bare X at EOF. - # This emitter diffs cumulative snapshots and drops the shrink, so without a - # closing pass the client text would end on an unclosed . finish() - # must balance it. + # Anthropic asks the GGUF generator not to promote reasoning into a duplicate + # visible fallback, so its final cumulative snapshot only balances the block. emitter = AnthropicStreamEmitter() events = emitter.start("msg_1", "m") events += emitter.feed({"type": "content", "text": "The capital"}) events += emitter.feed({"type": "content", "text": "The capital of France is Paris."}) - # The generator's final bare-text shrink (dropped by the cumulative diff). - events += emitter.feed({"type": "content", "text": "The capital of France is Paris."}) + events += emitter.feed( + {"type": "content", "text": "The capital of France is Paris."} + ) events += emitter.finish() assert _emitter_client_text(events) == "The capital of France is Paris." @@ -1563,6 +1562,44 @@ class TestAnthropicMessagesToolRouting: assert entry["context_length"] == 2048 assert monitor.active_count() == 0 + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("with_tools", [False, True]) + def test_reasoning_only_output_is_not_duplicated(self, monkeypatch, stream, with_tools): + reasoning = "The capital of France is Paris." + + def _gen_plain(**kwargs): + assert kwargs["promote_reasoning_only"] is False + yield f"{reasoning}" + yield f"{reasoning}" + + def _gen_tools(**kwargs): + assert kwargs["promote_reasoning_only"] is False + yield {"type": "content", "text": f"{reasoning}"} + yield {"type": "content", "text": f"{reasoning}"} + + _mock_backend( + monkeypatch, + generate_chat_completion = _gen_plain, + generate_chat_completion_with_tools = _gen_tools, + ) + payload_fields = {"stream": stream} + if with_tools: + payload_fields.update( + { + "enable_tools": True, + "tools": [{"type": "web_search_20250305", "name": "web_search"}], + } + ) + payload = _basic_payload(**payload_fields) + + response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t")) + if stream: + body = self._sse_blob(self._consume_response(response)) + assert body.count(reasoning) == 1 + else: + body = json.loads(response.body) + assert body["content"][0]["text"] == f"{reasoning}" + def test_tool_use_non_streaming_records_api_monitor_reply(self, monkeypatch): import routes.inference as inf_mod diff --git a/studio/backend/tests/test_gguf_load_cache_reuse.py b/studio/backend/tests/test_gguf_load_cache_reuse.py index 6d1fac980b..dfd6fc6034 100644 --- a/studio/backend/tests/test_gguf_load_cache_reuse.py +++ b/studio/backend/tests/test_gguf_load_cache_reuse.py @@ -789,9 +789,12 @@ class TestLoadHubDownloadExclusion: # The gguf_load_in_flight marker must be entered before the hub-download # guard and the unload so a concurrent load can't race the download - # manager. The llama_extra_args inheritance that used to sit between the - # marker and the guard now runs in _guard_chat_load_against_training, ahead - # of the GGUF branch, so it is no longer a landmark inside this slice. + # manager. The llama_extra_args inheritance moved out of the branch into + # _resolve_inherited_extra_args, which must still run BEFORE it: the + # inherited value (e.g. a carried --no-mmproj) shapes the guard's + # require_mmproj. Anchor on the call form so the assertion pins the + # endpoint's call site, not the function definition. + assert source.index("= _resolve_inherited_extra_args(") < source.index("if config.is_gguf:") assert ( gguf_branch.index("enter_context(gguf_load_in_flight") < gguf_branch.index("_hub_download_blocks_gguf_load") diff --git a/studio/backend/tests/test_llama_cpp_tool_loop.py b/studio/backend/tests/test_llama_cpp_tool_loop.py index fbc1ebf538..1268d16210 100644 --- a/studio/backend/tests/test_llama_cpp_tool_loop.py +++ b/studio/backend/tests/test_llama_cpp_tool_loop.py @@ -37,6 +37,24 @@ def _done() -> str: return "data: [DONE]\n" +def _finish(reason: str) -> str: + return ( + "data: " + + json.dumps( + { + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": reason, + } + ] + } + ) + + "\n" + ) + + def _make_backend( monkeypatch, streams: list[object], @@ -327,9 +345,8 @@ def test_reasoning_streams_incrementally_with_tools(monkeypatch): def test_reasoning_only_reply_matches_no_tool_path_with_tools(monkeypatch): # A reasoning-only turn (whole answer in reasoning_content, no content, no # tool) with a tool active streams the reasoning live, then resolves to the - # bare reasoning text -- identical to the no-tool generate_chat_completion - # path -- so the non-streaming drain still returns it as `content`, not an - # empty answer. + # same text on the visible channel. The final cumulative snapshot stays + # append-only so route suffix extraction cannot drop that fallback. stream = [ _sse({"reasoning_content": "The capital of France is Paris."}), _done(), @@ -349,8 +366,49 @@ def test_reasoning_only_reply_matches_no_tool_path_with_tools(monkeypatch): content_texts = [e["text"] for e in events if e["type"] == "content"] # Reasoning streamed live during BUFFERING (the fix). assert content_texts[0] == "The capital of France is Paris." - # Resolves to bare reasoning, matching the no-tool sibling. - assert content_texts[-1] == "The capital of France is Paris." + assert content_texts[-1] == ( + "The capital of France is Paris.The capital of France is Paris." + ) + + +def _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, with_tools): + stream = [ + _sse({"reasoning_content": "The capital of France is Paris."}), + _done(), + ] + backend = _make_backend(monkeypatch, [stream], []) + + if with_tools: + items = list( + backend.generate_chat_completion_with_tools( + messages = [{"role": "user", "content": "capital of France?"}], + tools = [{"type": "function", "function": {"name": "web_search"}}], + max_tool_iterations = 1, + promote_reasoning_only = False, + ) + ) + cumulatives = [item["text"] for item in items if item.get("type") == "content"] + else: + items = list( + backend.generate_chat_completion( + messages = [{"role": "user", "content": "capital of France?"}], + promote_reasoning_only = False, + ) + ) + cumulatives = [item for item in items if isinstance(item, str)] + + assert cumulatives[-1] == "The capital of France is Paris." + assert all( + current.startswith(previous) for previous, current in zip([""] + cumulatives, cumulatives) + ) + + +def test_reasoning_only_raw_consumer_without_tools_gets_one_balanced_think_block(monkeypatch): + _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, False) + + +def test_reasoning_only_raw_consumer_with_tools_gets_one_balanced_think_block(monkeypatch): + _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, True) def test_reasoning_before_structured_tool_closes_think_block(monkeypatch): @@ -420,8 +478,8 @@ def _replay_route_reasoning_extractor(cumulatives: list[str]) -> tuple[str, str] def test_reasoning_only_route_output_matches_no_tool_path(monkeypatch): # Parity contract: a reasoning-only reply must reach the client identically # whether tools are on or off. Both generators stream live then - # resolve to the bare reasoning text; the route's suffix-diff + extractor - # must therefore produce the same (visible, reasoning) split for both. + # append a balanced close plus visible fallback; the route's suffix-diff + + # extractor must therefore produce the same split for both. stream = [ _sse({"reasoning_content": "The capital"}), _sse({"reasoning_content": " of France is Paris."}), @@ -458,10 +516,37 @@ def test_reasoning_only_route_output_matches_no_tool_path(monkeypatch): no_tool_out = _replay_route_reasoning_extractor(no_tool_cumulatives) assert tool_out == no_tool_out # Pin the shared contract so a change to either path shows up here. - _visible, reasoning = tool_out + visible, reasoning = tool_out + assert visible == "The capital of France is Paris." assert reasoning == "The capital of France is Paris." +def test_length_truncated_reasoning_stays_append_only_without_visible_promotion(monkeypatch): + stream = [ + _sse({"reasoning_content": "The proof begins by assuming finitely many primes."}), + _finish("length"), + _done(), + ] + backend = _make_backend(monkeypatch, [stream], []) + + items = list( + backend.generate_chat_completion( + messages = [{"role": "user", "content": "Prove infinitely many primes"}], + max_tokens = 16, + ) + ) + cumulatives = [item for item in items if isinstance(item, str)] + + assert all( + current.startswith(previous) for previous, current in zip([""] + cumulatives, cumulatives) + ) + assert cumulatives[-1] == ("The proof begins by assuming finitely many primes.") + visible, reasoning = _replay_route_reasoning_extractor(cumulatives) + assert visible == "" + assert reasoning == "The proof begins by assuming finitely many primes." + assert items[-1]["finish_reason"] == "length" + + def test_reasoning_before_bare_json_tool_closes_think_block(monkeypatch): # _drain_silently sibling of the structured-tool close: a bare-JSON tool call # with a live reasoning prefix must also close before draining, and diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index e6c89b9cd7..9232defd70 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -524,8 +524,10 @@ export function AppProvider({ children }: AppProviderProps) { visibleToasts={2} expand={true} closeButton={true} - // Clear the chat header buttons on the right. - offset={{ top: 12, right: 64 }} + // Clear the chat header buttons on the right. On desktop, also drop + // below the ~34px custom window titlebar so toasts don't cover the + // minimize / maximize / close controls. + offset={{ top: isTauri ? 46 : 12, right: 64 }} /> diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index d4861e8a3a..b7323777b2 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -89,6 +89,7 @@ import { import { resolveLoadMaxSeqLength } from "../presets/preset-policy"; import { generateAudio, + GenerationLengthError, listCachedGguf, listCachedModels, listGgufVariants, @@ -4093,7 +4094,15 @@ export function createOpenAIStreamAdapter( ); if (!abortSignal.aborted) { const msg = err instanceof Error ? err.message : String(err); - if (err instanceof StreamInterruptedError) { + if (err instanceof GenerationLengthError) { + toast.error("Response ran out of tokens", { + description: + "The model used the full Max Tokens budget while thinking " + + "and did not produce a final answer. Increase Max Tokens in " + + "chat Settings or turn off thinking, then retry.", + duration: 8000, + }); + } else if (err instanceof StreamInterruptedError) { // Connection dropped mid-turn: surface it explicitly (the rethrow // below also marks the message with an inline error + Retry). toast.error("Response interrupted", { diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index ffaf099f29..4d123e98ab 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -50,6 +50,21 @@ export class StreamInterruptedError extends Error { } } +/** + * Thrown when a reasoning model consumes its output budget before emitting any + * standard content. Keeping this distinct from a dropped connection lets the + * chat UI explain why a completed stream contains only a thinking panel. + */ +export class GenerationLengthError extends Error { + constructor() { + super( + "The model reached the Max Tokens limit before producing a final answer. " + + "Increase Max Tokens or disable thinking, then retry.", + ); + this.name = "GenerationLengthError"; + } +} + export function notifyChatHistoryUpdated(): void { if (typeof window !== "undefined") { window.dispatchEvent(new Event(CHAT_HISTORY_UPDATED_EVENT)); @@ -982,6 +997,61 @@ function parseSseEvent(rawEvent: string): string[] { return dataLines; } +function hasNonWhitespaceText(value: unknown): boolean { + if (typeof value === "string") { + return value.trim().length > 0; + } + if (Array.isArray(value)) { + return value.some((item) => hasNonWhitespaceText(item)); + } + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ["thinking", "text", "content", "reasoning", "summary"].some( + (key) => key in record && hasNonWhitespaceText(record[key]), + ); +} + +function classifyStructuredDeltaContent(content: unknown): { + hasAssistantContent: boolean; + hasReasoningContent: boolean; +} { + if (typeof content === "string") { + return { + hasAssistantContent: hasNonWhitespaceText(content), + hasReasoningContent: false, + }; + } + if (!Array.isArray(content)) { + return { + hasAssistantContent: false, + hasReasoningContent: false, + }; + } + + let hasAssistantContent = false; + let hasReasoningContent = false; + for (const part of content) { + if (typeof part === "string") { + hasAssistantContent ||= hasNonWhitespaceText(part); + continue; + } + if (!part || typeof part !== "object") { + continue; + } + const record = part as Record; + if (record.type === "thinking" || record.type === "reasoning") { + hasReasoningContent ||= hasNonWhitespaceText(record); + } else if (record.type === "text" || record.type === "output_text") { + const text = + typeof record.text === "string" ? record.text : record.content; + hasAssistantContent ||= hasNonWhitespaceText(text); + } + } + return { hasAssistantContent, hasReasoningContent }; +} + export async function* streamChatCompletions( payload: OpenAIChatCompletionsRequest, signal: AbortSignal, @@ -1009,6 +1079,19 @@ export async function* streamChatCompletions( // EOF without `[DONE]` or a finish_reason chunk means the stream was cut // mid-generation: surface as interrupted, not silent success. let sawTerminalSignal = false; + let terminalFinishReason: string | null = null; + let sawAssistantContent = false; + let sawReasoningContent = false; + + const throwIfReasoningOnlyLength = () => { + if ( + terminalFinishReason === "length" && + sawReasoningContent && + !sawAssistantContent + ) { + throw new GenerationLengthError(); + } + }; try { while (true) { @@ -1018,6 +1101,7 @@ export async function* streamChatCompletions( if (!sawTerminalSignal) { throw new StreamInterruptedError(); } + throwIfReasoningOnlyLength(); break; } @@ -1039,6 +1123,7 @@ export async function* streamChatCompletions( if (dataText === "[DONE]") { completed = true; sawTerminalSignal = true; + throwIfReasoningOnlyLength(); return; } @@ -1094,11 +1179,31 @@ export async function* streamChatCompletions( } // finish_reason is a valid terminal signal for providers that close // the stream without an explicit [DONE] sentinel. - const finishReason = ( + const parsedChoices = ( parsed as { - choices?: Array<{ finish_reason?: string | null }>; + choices?: Array<{ + delta?: Record; + 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 ( -
-
+