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": [ {