From 478d30f361a05f25a4e386c04175011eebcb507c Mon Sep 17 00:00:00 2001 From: Leo Borcherding Date: Fri, 24 Jul 2026 22:23:41 -0500 Subject: [PATCH] Unsloth Studio (desktop): fix canvas preview, download file button, toast placement, and model-load typing lag (#7391) * Studio desktop: fix loading-toast overlap and typing lag on model load - Toaster: on desktop, offset toasts below the ~34px custom window titlebar (top 46 when isTauri) so they no longer cover the min/max/close controls. Web is unchanged (top 12). - Model load: the 2s load poll wrote loadProgress state every tick, which re-renders the whole chat page during "Starting model" (cheap in Chrome, janky in the desktop WebView2 -> laggy typing). That state is only read by the dismissed-toast inline status, so gate all four poll branches to write it only when the inline view is live; while the toast is up it updates via Sonner alone. * Studio desktop: fix HTML canvas preview, download, and panel offset - CSP: add frame-src for localhost/127.0.0.1 so the desktop webview can frame the backend-served artifact preview. default-src 'self' (no frame-src) blocked it -> "127.0.0.1 refused to connect"; web is same-origin so it already worked. - Download: route the canvas Download button through the native save dialog (downloadFile) instead of a blob-anchor click, which the Tauri WebView2 silently drops. - Nudge the artifact panel down 8px so its top edge/shadow isn't tucked under the window top bar. * Studio desktop: add HTML filter for native canvas save dialog Canvas Download saves .html via save_native_file, but save_filter() had no html/htm case, so the native dialog fell back to the JSON/CSV/etc filter and could block saving/browsing the .html export. Add an HTML filter and include html/htm in the catch-all. Addresses Codex review on #7391. * Studio desktop: unblock canvas preview in dev shell + clear header fade - Preview: the app CSP frame-src fix wasn't enough in the tauri dev shell. The preview endpoint sets its own frame-ancestors response header, which only allowed 'self' tauri://localhost http://tauri.localhost -- so the Vite dev origin (http://localhost:5173) was blocked and the frame stayed "refused to connect". Extend the allowlist with http://localhost:* and http://127.0.0.1:* (the endpoint only renders postMessage'd HTML in a no-same-origin sandbox, so it exposes no server resource). - Shadow: the artifact panel toolbar sat under the full-width chat-header-fade; lower the panel top (mt 80->90px) so the controls clear the fade. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/routes/inference.py | 11 +++- studio/frontend/src/app/provider.tsx | 6 +- .../chat/artifacts/artifact-surface.tsx | 30 ++++----- .../chat/hooks/use-chat-model-runtime.ts | 62 ++++++++++++------- studio/src-tauri/src/native_file_dialogs.rs | 11 +++- studio/src-tauri/tauri.conf.json | 2 +- 6 files changed, 80 insertions(+), 42 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 4e74e1d32d..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'; " 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/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/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 76a310ac33..bb19223a6a 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 @@ -1271,12 +1271,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 +1305,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 +1375,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/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": [ {