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:
parent
938e786eb9
commit
478d30f361
6 changed files with 80 additions and 42 deletions
|
|
@ -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'; "
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue