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>
This commit is contained in:
Leo Borcherding 2026-07-24 22:23:41 -05:00 committed by GitHub
commit 478d30f361
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 80 additions and 42 deletions

View file

@ -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'; "

View file

@ -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 }}
/>
</TooltipProvider>
</MotionConfig>

View file

@ -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"
>
<HugeiconsIcon icon={Download01Icon} className="size-4" />

View file

@ -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(

View file

@ -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");

View file

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