unsloth/studio/frontend/src/features/export/components/export-dialog.tsx
Daniel Han 7252410ccc
studio: stream export worker output into the export dialog (#4897)
* studio: stream export worker output into the export dialog

The Export Model dialog only showed a spinner on the "Exporting..."
button while the worker subprocess was doing the actual heavy lifting.
For Merged to 16bit and GGUF / Llama.cpp exports this meant several
minutes (or more, for large models) of opaque silence, with no way to
tell whether save_pretrained_merged, convert_hf_to_gguf.py, or
llama-quantize was making progress.

This adds a live terminal-style output panel inside the export dialog,
rendered just above the Cancel / Start Export buttons and scrollable
with auto-follow-tail. It shows stdout and stderr from both the worker
process itself and any child process it spawns (GGUF converter,
llama-quantize), coloured by stream.

Backend

- core/export/worker.py: new _setup_log_capture(resp_queue) installed
  before LogConfig.setup_logging. It saves the original stdout/stderr
  fds, creates pipes, os.dup2's the write ends onto fds 1 and 2 (so
  every child process inherits the redirected fds), and spins up two
  daemon reader threads. Each thread reads bytes from a pipe, echoes
  them back to the original fd (so the server console keeps working),
  splits on \n and \r, and forwards each line to the resp queue as
  {"type":"log","stream":"stdout|stderr","line":...,"ts":...}.
  PYTHONUNBUFFERED=1 is set so nested Python converters flush
  immediately.

- core/export/orchestrator.py:
  - Thread-safe ring buffer (collections.deque, maxlen 4000) with a
    monotonically increasing seq counter. clear_logs(),
    get_logs_since(cursor), get_current_log_seq(), is_export_active().
  - _wait_response handles rtype == "log" by appending to the buffer
    and continuing the wait loop. Status messages are also surfaced as
    a "status" stream so users see high level progress alongside raw
    subprocess output.
  - load_checkpoint, _run_export, and cleanup_memory now wrap their
    bodies with the existing self._lock (previously unused), clear the
    log buffer at the start of each op, and flip _export_active in a
    try/finally so the SSE endpoint can detect idle.

- routes/export.py:
  - Wrapped every sync orchestrator call (load_checkpoint,
    cleanup_memory, export_merged_model, export_base_model,
    export_gguf, export_lora_adapter) in asyncio.to_thread so the
    FastAPI event loop stays free during long exports. Without this
    the new SSE endpoint could not be served concurrently with the
    blocking export POST.
  - New GET /api/export/logs/stream SSE endpoint. Honors
    Last-Event-ID and a since query param for reconnect, emits log /
    heartbeat / complete / error events, uses the id field to carry
    the log seq so clients can resume cleanly. On first connect
    without an explicit cursor it starts from the current seq so old
    lines from a previous run are not replayed.

Frontend

- features/export/api/export-api.ts: streamExportLogs() helper that
  authFetches the SSE endpoint and parses id / event / data fields
  manually (same pattern as streamTrainingProgress in train-api.ts).

- features/export/components/export-dialog.tsx:
  - Local useExportLogs(exporting) hook that opens the SSE stream on
    exporting transitions to true, accumulates up to 4000 lines in
    component state, and aborts on cleanup.
  - New scrollable output panel rendered above DialogFooter, only
    shown for Merged to 16bit and GGUF / Llama.cpp (LoRA adapter is
    a fast disk write with nothing to show). Dark terminal styling
    (bg-black/85, emerald text, rose for stderr, sky for status),
    max-height 14rem, auto-scrolls to the bottom on new output but
    stops following if the user scrolls up. A small streaming / idle
    indicator is shown next to the panel title.
  - DialogContent widens from sm:max-w-lg to sm:max-w-2xl when the
    output panel is visible so the logs have room to breathe.

Verified

- Python smoke test (tests/smoke_export_log_capture.py): spawns a
  real mp.get_context("spawn") process, installs _setup_log_capture,
  confirms that parent stdout prints, parent stderr prints, AND a
  child subprocess invoked via subprocess.run (both its stdout and
  stderr) are all captured in the resp queue. Passes.
- Orchestrator log helpers tested in isolation: _append_log,
  get_logs_since (with and without a cursor), clear_logs not
  resetting seq so reconnecting clients still progress. Passes.
- routes.export imports cleanly in the studio venv and /logs/stream
  shows up in router.routes.
- bun run build: tsc -b plus vite build, no TypeScript errors.

No existing export behavior is changed. If the subprocess, the SSE
endpoint, or the frontend hook fails, the export itself still runs to
completion the same way it did before, with or without logs visible.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* export dialog: trim bootstrap noise, scope logs per screen, show realpath

Several follow-ups to the live export log work:

1. Worker bootstrap noise (transformers venv activation, Unsloth banner,
   "Top GGUF/hub models" lists, vision detection, 2k-step weight load
   bar) is dropped from the export-dialog stream. A threading.Event
   gate in worker.py defaults closed and only opens once _handle_export
   actually starts; until then the reader thread still echoes lines to
   the saved console fd for debugging but does not push them onto the
   resp_queue. The orchestrator already spawns a fresh subprocess for
   every checkpoint load, so the gate is naturally reset between runs.

2. tqdm in non-tty mode defaults to a 10s mininterval, which makes
   multi-step bars look frozen in the panel. Set TQDM_MININTERVAL=0.5
   in the worker env so any tqdm-driven progress emits more often.

3. The dialog's useExportLogs hook now also clears its line buffer
   when exportMethod or open changes, so re-opening the dialog into a
   different action's screen no longer shows the previous action's
   saved output. A useElapsedSeconds tick + "Working Xs" badge in the
   log header gives users a visible sign that long single-step phases
   (cache copies, GGUF conversion) are still running when no new lines
   are arriving.

4. ExportBackend.export_{merged,base,gguf,lora} now return
   (success, message, output_path); the worker forwards output_path on
   each export_*_done response, the orchestrator's _run_export passes
   it to routes/export.py, which surfaces it via
   ExportOperationResponse.details.output_path. The dialog's Export
   Complete screen renders the resolved on-disk realpath under "Saved
   to" so users can find their exported model directly.

* fix(cli): unpack 3-tuple return from export backend

ExportOrchestrator.export_{merged,base,gguf,lora} now return
(success, message, output_path) so the studio dialog can show
the on-disk realpath. The CLI still unpacked 2 values, so every
`unsloth export --format ...` crashed with ValueError before
reporting completion. Update the four call sites and surface
output_path via a "Saved to:" echo.

* fix(studio): anchor export log SSE cursor at run start

The export dialog SSE defaulted its cursor to get_current_log_seq()
at connect time, so any line emitted between the POST that kicks
off the export and the client opening the stream was buffered with
seqs 1..k and then skipped (seq <= cursor). Long-running exports
looked silent during their first seconds.

Snapshot _log_seq into _run_start_seq inside clear_logs() and
expose it via get_run_start_seq(). The SSE default cursor now uses
that snapshot, so every line emitted since the current run began
is reachable regardless of when the client connects. Old runs
still can't leak in because their seqs are <= the snapshot.

* fix(studio): reconnect export log SSE on stream drop

useExportLogs launched streamExportLogs once per exporting
transition and recorded any drop in .catch(). Long GGUF exports
behind a proxy with an idle kill-timeout would silently lose the
stream for the rest of the run even though the backend already
supports Last-Event-ID resume. The "retry: 3000" directive emitted
by the backend is only meaningful to native EventSource; this
hook uses a manual fetch + ReadableStream parse so it had no
effect.

Wrap streamExportLogs in a retry loop that tracks lastSeq from
ExportLogEvent.id and passes it as since on reconnect. Backoff is
exponential with jitter, capped at 5s, reset on successful open.
The loop stops on explicit backend `complete` event or on effect
cleanup.

* fix(studio): register a second command so Typer keeps `export` as a subcommand

The CLI export unpacking tests wrap `unsloth_cli.commands.export.export`
in a fresh Typer app with a single registered command. Typer flattens a
single-command app into that command, so the test's
`runner.invoke(cli_app, ["export", ckpt, out, ...])` treats the leading
`"export"` token as an unexpected extra positional argument -- every
parametrized case failed with:

    Got unexpected extra argument (.../out)

Register a harmless `noop` second command so Typer preserves subcommand
routing and the tests actually exercise the 3-tuple unpack path they
were written to guard.

Before: 4 failed
After:  4 passed

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: studio-install <studio@local.install>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
2026-04-14 08:55:43 -07:00

598 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { AlertCircleIcon, ArrowRight01Icon, CheckmarkCircle02Icon, Key01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { streamExportLogs, type ExportLogEntry } from "../api/export-api";
import { collapseAnim } from "../anim";
import { EXPORT_METHODS, type ExportMethod } from "../constants";
// Max log lines kept in the dialog's local state. Matches the backend
// ring buffer's maxlen so the UI shows the full scrollback captured
// server side.
const MAX_LOG_LINES = 4000;
interface UseExportLogsResult {
lines: ExportLogEntry[];
connected: boolean;
error: string | null;
}
/**
* Subscribe to the live export log SSE stream while `exporting` is
* true, and accumulate lines in local state. Lines from a previous
* action are cleared:
*
* - when a new export starts (`exporting` flips to true), and
* - when the user switches export method, dialog opens fresh, or
* the dialog closes — so re-opening into a different action's
* screen doesn't show the prior screen's saved output.
*/
function useExportLogs(
exporting: boolean,
exportMethod: ExportMethod | null,
open: boolean,
): UseExportLogsResult {
const [lines, setLines] = useState<ExportLogEntry[]>([]);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset log state whenever the user moves to a different screen --
// either by switching export method or by reopening the dialog -- so
// each (open × method) tuple shows only its own run history. The
// streaming effect below additionally clears on new export start.
useEffect(() => {
setLines([]);
setError(null);
setConnected(false);
}, [exportMethod, open]);
useEffect(() => {
if (!exporting) return;
setLines([]);
setError(null);
const abortCtrl = new AbortController();
let cancelled = false;
// Track the highest seq we've observed on a `log` event so we can
// resume the stream via `since=` / `Last-Event-ID` after a drop.
// The backend's SSE `id:` field carries this as ExportLogEvent.id.
let lastSeq: number | null = null;
// Exponential backoff with jitter, capped. Reset on every
// successful connection so flaky networks don't accumulate delay.
let backoffMs = 500;
const MAX_BACKOFF_MS = 5000;
// Flipped by a terminal event (explicit `complete` from the
// backend or a non-transient error we choose not to retry). Stops
// the outer reconnect loop even if `exporting` is still true.
let terminated = false;
const run = async () => {
while (!cancelled && !terminated) {
try {
await streamExportLogs({
signal: abortCtrl.signal,
since: lastSeq,
onOpen: () => {
if (cancelled) return;
setConnected(true);
// Reset backoff on every successful connect so later
// drops don't inherit accumulated delay from earlier ones.
backoffMs = 500;
},
onEvent: (event) => {
if (cancelled) return;
if (event.event === "log" && event.entry) {
if (typeof event.id === "number") {
lastSeq = event.id;
}
const entry = event.entry;
setLines((prev) => {
const next = prev.length >= MAX_LOG_LINES
? prev.slice(prev.length - MAX_LOG_LINES + 1)
: prev.slice();
next.push(entry);
return next;
});
} else if (event.event === "complete") {
// Backend signalled the run is fully drained -- stop
// trying to reconnect even though `exporting` may not
// have flipped false yet on this tick.
terminated = true;
} else if (event.event === "error" && event.error) {
setError(event.error);
}
},
});
} catch (err: unknown) {
if (cancelled) return;
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : String(err));
// Fall through to the backoff path below; a fetch-level
// failure is retryable the same way a clean EOF is.
}
setConnected(false);
if (cancelled || terminated) return;
// Exponential backoff with jitter before reconnecting. The
// backend's ring buffer plus Last-Event-ID resume means we
// don't lose lines across the retry as long as the reconnect
// happens within the buffer's lifetime (~4000 lines).
const delay = backoffMs + Math.floor(Math.random() * 250);
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
try {
await new Promise<void>((resolve, reject) => {
if (abortCtrl.signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const timeoutId = window.setTimeout(resolve, delay);
abortCtrl.signal.addEventListener(
"abort",
() => {
window.clearTimeout(timeoutId);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
} catch {
return;
}
}
};
// run()'s own try/catch handles every failure path we care about;
// swallow anything that somehow escapes so React's dev overlay
// doesn't flag an unhandled rejection on dialog close.
void run().catch(() => {});
return () => {
cancelled = true;
abortCtrl.abort();
setConnected(false);
};
}, [exporting]);
return { lines, connected, error };
}
/**
* Tick every second while `exporting` is true and report elapsed
* seconds. Powers the "Working… 27s" badge in the log header so the
* panel doesn't look frozen during long single-step phases (cache
* file copy, GGUF conversion) when no new lines are arriving.
*/
function useElapsedSeconds(exporting: boolean): number {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!exporting) {
setElapsed(0);
return;
}
const startedAt = Date.now();
setElapsed(0);
const id = window.setInterval(() => {
setElapsed(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => window.clearInterval(id);
}, [exporting]);
return elapsed;
}
function formatElapsed(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s.toString().padStart(2, "0")}s`;
}
function formatLogLine(entry: ExportLogEntry): string {
// Strip trailing carriage returns that tqdm-style progress leaves
// in the stream so the scrollback doesn't render funky boxes.
return entry.line.replace(/\r+$/g, "");
}
type Destination = "local" | "hub";
interface ExportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
checkpoint: string | null;
exportMethod: ExportMethod | null;
quantLevels: string[];
estimatedSize: string;
baseModelName: string;
isAdapter: boolean;
destination: Destination;
onDestinationChange: (v: Destination) => void;
hfUsername: string;
onHfUsernameChange: (v: string) => void;
modelName: string;
onModelNameChange: (v: string) => void;
hfToken: string;
onHfTokenChange: (v: string) => void;
privateRepo: boolean;
onPrivateRepoChange: (v: boolean) => void;
onExport: () => void;
exporting: boolean;
exportError: string | null;
exportSuccess: boolean;
/**
* Resolved on-disk realpath of the most recent successful export.
* Surfaced on the Export Complete screen so users can find their
* model. Null when the export only pushed to the Hub.
*/
exportOutputPath: string | null;
}
export function ExportDialog({
open,
onOpenChange,
checkpoint,
exportMethod,
quantLevels,
estimatedSize: _estimatedSize,
baseModelName,
isAdapter,
destination,
onDestinationChange,
hfUsername,
onHfUsernameChange,
modelName,
onModelNameChange,
hfToken,
onHfTokenChange,
privateRepo,
onPrivateRepoChange,
onExport,
exporting,
exportError,
exportSuccess,
exportOutputPath,
}: ExportDialogProps) {
// Live log capture is only meaningful for export methods that run
// a slow subprocess operation with interesting stdout: merged and
// gguf. LoRA adapter export is a fast disk write and would just
// show a blank panel, so we hide it there.
const showLogPanel =
exportMethod === "merged" || exportMethod === "gguf";
const { lines: logLines, connected: logConnected, error: logError } =
useExportLogs(exporting && showLogPanel, exportMethod, open);
const elapsedSeconds = useElapsedSeconds(exporting && showLogPanel);
const logScrollRef = useRef<HTMLDivElement | null>(null);
// Auto-scroll to bottom whenever a new line arrives, unless the
// user has scrolled up to read earlier output.
const [followTail, setFollowTail] = useState(true);
useEffect(() => {
if (!followTail) return;
const el = logScrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [logLines, followTail]);
const handleLogScroll = () => {
const el = logScrollRef.current;
if (!el) return;
const nearBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 24;
setFollowTail(nearBottom);
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (exporting) return;
onOpenChange(v);
}}
>
<DialogContent
className={showLogPanel ? "sm:max-w-2xl" : "sm:max-w-lg"}
onInteractOutside={(e) => { if (exporting) e.preventDefault(); }}
>
{exportSuccess ? (
<>
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex size-12 items-center justify-center rounded-full bg-emerald-500/10">
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="size-6 text-emerald-500" />
</div>
<div className="flex flex-col items-center gap-2 text-center">
<h3 className="text-lg font-semibold">Export Complete</h3>
<p className="text-sm text-muted-foreground">
{destination === "hub"
? "Model successfully pushed to Hugging Face Hub."
: "Model saved locally."}
</p>
{exportOutputPath ? (
<div className="mt-1 flex w-full max-w-md flex-col items-stretch gap-1 rounded-lg border border-border/40 bg-muted/40 px-3 py-2 text-left">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Saved to
</span>
<code
className="select-all break-all font-mono text-[12px] text-foreground"
title={exportOutputPath}
>
{exportOutputPath}
</code>
</div>
) : null}
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Export Model</DialogTitle>
<DialogDescription>
Choose where to save your exported model.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Button
variant={destination === "local" ? "dark" : "outline"}
onClick={() => onDestinationChange("local")}
disabled={exporting}
className="flex-1"
>
Save Locally
</Button>
<Button
variant={destination === "hub" ? "dark" : "outline"}
onClick={() => onDestinationChange("hub")}
disabled={exporting}
className="flex-1"
>
Push to Hub
</Button>
</div>
<AnimatePresence>
{destination === "hub" && (
<motion.div {...collapseAnim} className="overflow-hidden">
<div className="flex flex-col gap-4 px-0.5">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Username / Org
</label>
<Input
placeholder="your-username"
value={hfUsername}
onChange={(e) => onHfUsernameChange(e.target.value)}
disabled={exporting}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Model Name
</label>
<Input
placeholder="my-model-gguf"
value={modelName}
onChange={(e) => onModelNameChange(e.target.value)}
disabled={exporting}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
HF Write Token
</label>
<a
href="https://huggingface.co/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors"
>
Get token
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3"
/>
</a>
</div>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
</InputGroupAddon>
<InputGroupInput
type="password"
autoComplete="new-password"
name="hf-token"
placeholder="hf_..."
value={hfToken}
onChange={(e) => onHfTokenChange(e.target.value)}
disabled={exporting}
/>
</InputGroup>
<p className="text-[11px] text-muted-foreground/70">
Leave empty if already logged in via CLI.
</p>
</div>
<div className="flex items-center gap-3">
<Switch
id="private-repo"
size="sm"
checked={privateRepo}
onCheckedChange={onPrivateRepoChange}
disabled={exporting}
/>
<label
htmlFor="private-repo"
className="text-xs font-medium cursor-pointer"
>
Private Repository
</label>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Error banner */}
{exportError && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 mt-0.5 shrink-0" />
<span>{exportError}</span>
</div>
)}
{/* Summary */}
<div className="rounded-xl bg-muted/50 p-3 text-xs text-muted-foreground flex flex-col gap-1">
<div className="flex justify-between">
<span>Base Model</span>
<span className="font-medium text-foreground">{baseModelName}</span>
</div>
<div className="flex justify-between">
<span>{isAdapter ? "Checkpoint" : "Model"}</span>
<span className="font-medium text-foreground">{checkpoint}</span>
</div>
<div className="flex justify-between">
<span>Export Method</span>
<span className="font-medium text-foreground">
{EXPORT_METHODS.find((m) => m.value === exportMethod)?.title}
</span>
</div>
{exportMethod === "gguf" && quantLevels.length > 0 && (
<div className="flex justify-between">
<span>Quantizations</span>
<span className="font-medium text-foreground">
{quantLevels.join(", ")}
</span>
</div>
)}
{/* TODO: unhide once estimated size comes from the backend API */}
{/* <div className="flex justify-between">
<span>Est. size</span>
<span className="font-medium text-foreground">{estimatedSize}</span>
</div> */}
</div>
{/* Live export output panel */}
<AnimatePresence>
{showLogPanel && (exporting || logLines.length > 0) && (
<motion.div {...collapseAnim} className="overflow-hidden">
<div className="flex flex-col gap-1.5 pt-1">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
Export output
</label>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/80">
<span
className={
logConnected
? "inline-block size-1.5 rounded-full bg-emerald-500"
: "inline-block size-1.5 rounded-full bg-muted-foreground/40"
}
/>
<span>
{logConnected
? "streaming"
: exporting
? "connecting..."
: "idle"}
</span>
{exporting && elapsedSeconds > 0 ? (
<span className="tabular-nums text-muted-foreground/70">
· {formatElapsed(elapsedSeconds)}
</span>
) : null}
</div>
</div>
<div
ref={logScrollRef}
onScroll={handleLogScroll}
className="h-56 w-full overflow-auto rounded-lg border border-border/40 bg-black/85 p-3 font-mono text-[11px] leading-[1.45] text-emerald-200/90"
>
{logLines.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground/70">
<span className="flex items-center gap-2">
<Spinner className="size-3" />
Waiting for worker output...
</span>
</div>
) : (
<pre className="whitespace-pre-wrap break-words">
{logLines.map((entry, idx) => (
<div
key={idx}
className={
entry.stream === "stderr"
? "text-rose-300/90"
: entry.stream === "status"
? "text-sky-300/90"
: ""
}
>
{formatLogLine(entry)}
</div>
))}
</pre>
)}
</div>
{logError && (
<p className="text-[11px] text-destructive/80">
Log stream: {logError}
</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={exporting}
>
Cancel
</Button>
<Button onClick={onExport} disabled={exporting}>
{exporting ? (
<span className="flex items-center gap-2">
<Spinner className="size-4" />
Exporting
</span>
) : (
"Start Export"
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}