feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX (#1963)

This commit is contained in:
Vance Ingalls
2026-07-06 16:43:48 -07:00
committed by GitHub
parent ec06f4bf89
commit 241f9d683e
19 changed files with 866 additions and 201 deletions
@@ -1,5 +1,6 @@
import { memo, useState, useRef, useEffect } from "react";
import { memo, useState, useRef, useEffect, useId } from "react";
import { RenderQueueItem } from "./RenderQueueItem";
import { Button } from "../ui/Button";
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
import { trackStudioEvent } from "../../utils/studioTelemetry";
@@ -20,9 +21,17 @@ interface RenderQueueProps {
jobs: RenderJob[];
projectId: string;
onDelete: (jobId: string) => void;
onCancel?: (jobId: string) => void;
onClearCompleted: () => void;
onStartRender: StartRenderHandler;
isRendering: boolean;
/** History fetch failure (null when the last load succeeded). */
loadError?: string | null;
/** Retry a failed history load. */
onRetryLoad?: () => void;
/** Failure of a delete/cancel action, shown inline until dismissed. */
actionError?: string | null;
onDismissActionError?: () => void;
/**
* Authored dimensions of the active composition. Used to pick the
* matching preset (landscape / portrait / square) when the user selects
@@ -110,9 +119,15 @@ function scaleOptionLabel(
dims: CompositionDimensions | null | undefined,
): string {
const resolved = resolvedDimensions(scale, dims);
return resolved
const base = resolved
? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}`
: SCALE_LABEL[scale];
// Explain *why* an option is disabled instead of greying it silently:
// the preset must be an exact integer upscale of the authored size.
if (dims && !scaleApplies(scale, dims)) {
return `${base} — not an integer scale of ${dims.width}×${dims.height}`;
}
return base;
}
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
@@ -127,9 +142,14 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
},
};
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a
// real button (focusable, labelled), the panel is tied to it via
// aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich
// for the one-line ui/Tooltip primitive, so this stays a local popover.
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
const [open, setOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const panelId = useId();
const show = () => {
clearTimeout(timeoutRef.current);
@@ -141,27 +161,51 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
useEffect(() => () => clearTimeout(timeoutRef.current), []);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
const info = FORMAT_INFO[format];
return (
<div className="relative" onPointerEnter={show} onPointerLeave={hide}>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help"
<button
type="button"
aria-label="About video formats"
aria-expanded={open}
aria-describedby={open ? panelId : undefined}
onFocus={show}
onBlur={hide}
onClick={() => setOpen((prev) => !prev)}
className="flex items-center justify-center p-0.5 -m-0.5 rounded text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</button>
{open && (
<div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50">
<div
id={panelId}
role="tooltip"
className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50"
>
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
@@ -191,14 +235,21 @@ const QUALITY_OPTIONS: {
{ value: "high", label: "High Quality", title: "Best quality, larger file" },
];
function formatEta(ms: number): string {
const s = Math.round(ms / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
function FormatExportButton({
onStartRender,
isRendering,
compositionDimensions,
lastRenderDurationMs,
}: {
onStartRender: StartRenderHandler;
isRendering: boolean;
compositionDimensions?: CompositionDimensions | null;
lastRenderDurationMs?: number;
}) {
const persisted = getPersistedRenderSettings();
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
@@ -293,16 +344,26 @@ function FormatExportButton({
</div>
)}
</div>
<button
<Button
variant="primary"
size="md"
loading={isRendering}
onClick={() => {
// loading already disables the button; this guard also stops a
// double-click in the same frame from enqueueing two renders.
if (isRendering) return;
trackStudioEvent("render_start", { format, quality, resolution, fps });
void onStartRender(format, quality, resolution, fps);
}}
disabled={isRendering}
className="w-full flex items-center justify-center h-8 text-[11px] font-semibold rounded-md bg-panel-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
className="w-full text-[11px] font-semibold"
>
{isRendering ? "Rendering..." : "Export"}
</button>
{isRendering ? "Rendering" : "Export"}
</Button>
{lastRenderDurationMs !== undefined && !isRendering && (
<p className="text-[9px] text-panel-text-5 text-center -mt-1.5">
Last render took {formatEta(lastRenderDurationMs)}
</p>
)}
</div>
);
}
@@ -311,9 +372,14 @@ export const RenderQueue = memo(function RenderQueue({
jobs,
projectId,
onDelete,
onCancel,
onClearCompleted,
onStartRender,
isRendering,
loadError,
onRetryLoad,
actionError,
onDismissActionError,
compositionDimensions,
}: RenderQueueProps) {
const listRef = useRef<HTMLDivElement>(null);
@@ -327,6 +393,9 @@ export const RenderQueue = memo(function RenderQueue({
}, [jobs.length]);
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
const lastRenderDurationMs = [...jobs]
.reverse()
.find((j) => j.status === "complete" && j.durationMs !== undefined)?.durationMs;
return (
<div className="flex flex-col h-full">
@@ -335,12 +404,40 @@ export const RenderQueue = memo(function RenderQueue({
onStartRender={onStartRender}
isRendering={isRendering}
compositionDimensions={compositionDimensions}
lastRenderDurationMs={lastRenderDurationMs}
/>
</div>
{actionError && (
<div
role="alert"
className="flex items-start justify-between gap-2 px-3 py-2 border-b border-panel-border bg-red-500/10"
>
<span className="text-[10px] text-red-400">{actionError}</span>
{onDismissActionError && (
<button
onClick={onDismissActionError}
aria-label="Dismiss error"
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 flex-shrink-0"
>
</button>
)}
</div>
)}
{/* Job list */}
<div ref={listRef} className="flex-1 overflow-y-auto">
{jobs.length === 0 ? (
{loadError && jobs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-4 gap-2" role="alert">
<p className="text-[10px] text-red-400 text-center">{loadError}</p>
{onRetryLoad && (
<Button size="sm" variant="secondary" onClick={onRetryLoad}>
Retry
</Button>
)}
</div>
) : jobs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
<svg
width="20"
@@ -376,11 +473,14 @@ export const RenderQueue = memo(function RenderQueue({
<span className="text-[10px] text-panel-text-4">
{jobs.length} render{jobs.length === 1 ? "" : "s"}
</span>
{/* "Hide", not "Clear": files stay on disk (delete is per-row
and confirmed); hidden rows don't resurrect on reload. */}
<button
onClick={onClearCompleted}
title="Hide finished renders from this list (files stay on disk)"
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
>
Clear
Hide finished
</button>
</div>
)}
@@ -390,6 +490,7 @@ export const RenderQueue = memo(function RenderQueue({
job={job}
projectId={projectId}
onDelete={() => onDelete(job.id)}
onCancel={() => onCancel?.(job.id)}
/>
))}
</div>
@@ -1,11 +1,13 @@
import { memo, useCallback, useState } from "react";
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
import { Button } from "../ui/Button";
import type { RenderJob } from "./useRenderQueue";
interface RenderQueueItemProps {
job: RenderJob;
projectId: string;
onDelete: () => void;
onCancel: () => void;
}
function formatDuration(ms: number): string {
@@ -27,8 +29,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
job,
projectId,
onDelete,
onCancel,
}: RenderQueueItemProps) {
const [hovered, setHovered] = useState(false);
const [videoReady, setVideoReady] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Direct file URL — serves from disk, survives server restarts
const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
@@ -50,25 +55,35 @@ export const RenderQueueItem = memo(function RenderQueueItem({
const viewSrc = fileSrc;
const isComplete = job.status === "complete";
const isRendering = job.status === "rendering";
return (
<div
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
onClick={isComplete ? handleOpen : undefined}
className={[
"px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150",
isComplete ? "cursor-pointer hover:bg-panel-hover/30" : "",
]
.filter(Boolean)
.join(" ")}
onPointerLeave={() => {
setHovered(false);
setVideoReady(false);
setConfirmingDelete(false);
}}
className="px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30"
>
<div className="flex items-center gap-2.5">
{/* Thumbnail — static frame; swaps to live video on hover */}
<div className="w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative">
{/* Thumbnail — static frame; swaps to live video on hover.
A real button so keyboard users can open the render too. */}
<button
type="button"
onClick={isComplete ? handleOpen : undefined}
disabled={!isComplete}
aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined}
className={[
"w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative",
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent",
isComplete ? "cursor-pointer" : "cursor-default",
].join(" ")}
>
{isComplete && (
<>
{/* Live video — visible on hover */}
{/* Live video — fades in over the static frame once it can play */}
{hovered && (
<video
src={viewSrc}
@@ -76,21 +91,23 @@ export const RenderQueueItem = memo(function RenderQueueItem({
muted
loop
playsInline
className="absolute inset-0 w-full h-full object-contain"
onCanPlay={() => setVideoReady(true)}
className="absolute inset-0 w-full h-full object-contain transition-opacity duration-150"
style={{ opacity: videoReady ? 1 : 0 }}
/>
)}
{/* Static frame — visible when not hovering */}
<div
className="absolute inset-0 transition-opacity duration-150"
style={{ opacity: hovered ? 0 : 1 }}
style={{ opacity: hovered && videoReady ? 0 : 1 }}
>
<VideoFrameThumbnail src={viewSrc} />
</div>
</>
)}
{job.status === "rendering" && (
{isRendering && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse" />
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse motion-reduce:animate-none" />
</div>
)}
{job.status === "failed" && (
@@ -103,7 +120,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
<div className="w-2 h-2 rounded-full bg-neutral-600" />
</div>
)}
</div>
</button>
{/* Info */}
<div className="flex-1 min-w-0">
@@ -118,13 +135,20 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
</div>
{job.status === "rendering" && (
{isRendering && (
<div className="mt-1">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
<span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
</div>
<div className="w-full h-1 bg-panel-border rounded-full overflow-hidden">
<div
className="w-full h-1 bg-panel-border rounded-full overflow-hidden"
role="progressbar"
aria-valuenow={job.progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Render progress: ${job.progress}%`}
>
<div
className="h-full bg-panel-accent rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
@@ -136,59 +160,103 @@ export const RenderQueueItem = memo(function RenderQueueItem({
{job.status === "failed" && job.error && (
<span className="text-[9px] text-red-400 mt-0.5 block">{job.error}</span>
)}
{job.status === "cancelled" && (
<span className="text-[9px] text-panel-text-4 mt-0.5 block">Cancelled</span>
)}
{job.status !== "rendering" && (
{!isRendering && (
<span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
)}
</div>
{/* Actions — always visible to prevent layout shifts */}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={isComplete ? handleDownload : undefined}
className={`p-1 rounded transition-colors ${
isComplete
? "text-panel-text-5 hover:text-panel-accent"
: "text-panel-text-5/30 pointer-events-none"
}`}
title={isComplete ? "Download" : "Rendering..."}
disabled={!isComplete}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<div className="flex items-center gap-1.5 flex-shrink-0">
{isRendering ? (
<Button
size="sm"
variant="secondary"
onClick={(e) => {
e.stopPropagation();
onCancel();
}}
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
title="Remove"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
Cancel
</Button>
) : confirmingDelete ? (
<>
<Button
size="sm"
variant="danger"
onClick={(e) => {
e.stopPropagation();
setConfirmingDelete(false);
onDelete();
}}
>
Delete?
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setConfirmingDelete(false);
}}
>
Keep
</Button>
</>
) : (
<>
<button
onClick={isComplete ? handleDownload : undefined}
className={`p-1.5 min-w-6 min-h-6 rounded transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
isComplete
? "text-panel-text-5 hover:text-panel-accent"
: "text-panel-text-5/30 cursor-default"
}`}
title={isComplete ? "Download" : undefined}
aria-label={`Download ${job.filename}`}
disabled={!isComplete}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmingDelete(true);
}}
className="p-1.5 min-w-6 min-h-6 rounded text-panel-text-5 hover:text-red-400 transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
title="Delete render file"
aria-label={`Delete ${job.filename}`}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</>
)}
</div>
</div>
</div>
@@ -36,23 +36,66 @@ export interface StartRenderOptions {
composition?: string;
}
// "Hide" (formerly "Clear") is a view operation, not a delete: hidden ids are
// remembered here so hidden renders don't resurrect from the on-disk history
// on the next load. Per-project key so projects don't hide each other's rows.
function hiddenIdsKey(projectId: string): string {
return `hf-studio-hidden-renders:${projectId}`;
}
function readHiddenIds(projectId: string): Set<string> {
try {
const raw = window.localStorage.getItem(hiddenIdsKey(projectId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : []);
} catch {
return new Set();
}
}
function writeHiddenIds(projectId: string, ids: Set<string>): void {
try {
// Cap the list so it doesn't grow unbounded across months of renders.
window.localStorage.setItem(hiddenIdsKey(projectId), JSON.stringify([...ids].slice(-200)));
} catch {
/* localStorage may be unavailable or full */
}
}
export function useRenderQueue(projectId: string | null) {
const [jobs, setJobs] = useState<RenderJob[]>([]);
// History fetch failure — distinguished from "no renders yet" so the panel
// never shows a false empty state.
const [loadError, setLoadError] = useState<string | null>(null);
// Failure of a user action (delete/cancel), surfaced inline in the panel.
const [actionError, setActionError] = useState<string | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const activeJobRef = useRef<string | null>(null);
const closeActiveEventSource = useCallback((jobId?: string) => {
if (jobId && activeJobRef.current !== jobId) return;
eventSourceRef.current?.close();
eventSourceRef.current = null;
activeJobRef.current = null;
}, []);
// Load completed renders from the server
const loadRenders = useCallback(async () => {
if (!projectId) return;
try {
const res = await fetch(`/api/projects/${projectId}/renders`);
if (!res.ok) return;
if (!res.ok) {
setLoadError(`Couldn't load render history (server error ${res.status}).`);
return;
}
const data = await res.json();
setLoadError(null);
if (Array.isArray(data.renders)) {
const hidden = readHiddenIds(projectId);
setJobs((prev) => {
const existing = new Set(prev.map((j) => j.id));
const fromServer: RenderJob[] = data.renders
.filter((r: { id: string }) => !existing.has(r.id))
.filter((r: { id: string }) => !existing.has(r.id) && !hidden.has(r.id))
.map(
(r: {
id: string;
@@ -74,7 +117,7 @@ export function useRenderQueue(projectId: string | null) {
});
}
} catch {
// ignore
setLoadError("Couldn't load render history. Is the studio server running?");
}
}, [projectId]);
@@ -175,6 +218,8 @@ export function useRenderQueue(projectId: string | null) {
es.addEventListener("progress", (event) => {
try {
const data = JSON.parse(event.data);
const terminal =
data.status === "complete" || data.status === "failed" || data.status === "cancelled";
setJobs((prev) =>
prev.map((j) =>
j.id === jobId
@@ -182,21 +227,15 @@ export function useRenderQueue(projectId: string | null) {
...j,
progress: data.progress ?? j.progress,
stage: data.stage ?? data.message ?? j.stage,
status:
data.status === "complete"
? "complete"
: data.status === "failed"
? "failed"
: j.status,
status: terminal ? (data.status as RenderJob["status"]) : j.status,
durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
error: data.error ?? j.error,
}
: j,
),
);
if (data.status === "complete" || data.status === "failed") {
es.close();
activeJobRef.current = null;
if (terminal) {
closeActiveEventSource(jobId);
}
} catch {
// ignore parse errors
@@ -221,21 +260,78 @@ export function useRenderQueue(projectId: string | null) {
return jobId;
},
[projectId],
[projectId, closeActiveEventSource],
);
const deleteRender = useCallback(async (jobId: string) => {
try {
await fetch(`/api/render/${jobId}`, { method: "DELETE" });
} catch {
// ignore
}
setJobs((prev) => prev.filter((j) => j.id !== jobId));
}, []);
// Cancel an in-flight render. The job row stays (as "cancelled") so the
// user sees the outcome; the SSE stream is closed either way.
const cancelRender = useCallback(
async (jobId: string) => {
setActionError(null);
closeActiveEventSource(jobId);
setJobs((prev) =>
prev.map((j) =>
j.id === jobId && j.status === "rendering" ? { ...j, status: "cancelled" } : j,
),
);
try {
const res = await fetch(`/api/render/${jobId}/cancel`, { method: "POST" });
if (!res.ok && res.status !== 404) {
setActionError("Couldn't cancel on the server — the render may still be running.");
return;
}
// Reconcile with the status the route reports: if the render actually
// finished (or failed) before the cancel landed, don't leave the row
// stuck on the optimistic "cancelled" — reload to pick up the real
// outcome (and the finished file's metadata).
if (res.ok) {
const body = (await res.json().catch(() => null)) as { status?: string } | null;
if (body?.status && body.status !== "cancelled") {
void loadRenders();
}
}
} catch {
setActionError("Couldn't reach the server to cancel — the render may still be running.");
}
},
[closeActiveEventSource, loadRenders],
);
const deleteRender = useCallback(
async (jobId: string) => {
setActionError(null);
closeActiveEventSource(jobId);
try {
const res = await fetch(`/api/render/${jobId}`, { method: "DELETE" });
if (!res.ok) {
setActionError("Couldn't delete the render — it's still on disk.");
return;
}
} catch {
setActionError("Couldn't reach the server to delete the render.");
return;
}
setJobs((prev) => prev.filter((j) => j.id !== jobId));
},
[closeActiveEventSource],
);
// Hide finished rows from the list (view-only — files stay on disk and can
// be recovered from the renders/ directory). Remembered per project so the
// rows don't resurrect from history on reload.
const clearCompleted = useCallback(() => {
setJobs((prev) => prev.filter((j) => j.status === "rendering"));
}, []);
setJobs((prev) => {
const finished = prev.filter((j) => j.status !== "rendering");
if (projectId && finished.length > 0) {
const hidden = readHiddenIds(projectId);
for (const j of finished) hidden.add(j.id);
writeHiddenIds(projectId, hidden);
}
return prev.filter((j) => j.status === "rendering");
});
}, [projectId]);
const dismissActionError = useCallback(() => setActionError(null), []);
// Clean up EventSource on unmount or projectId change
useEffect(() => {
@@ -250,10 +346,26 @@ export function useRenderQueue(projectId: string | null) {
() => ({
jobs,
isRendering,
loadError,
actionError,
dismissActionError,
reloadRenders: loadRenders,
deleteRender,
cancelRender,
clearCompleted,
startRender: startRender as (options: unknown) => Promise<void>,
}),
[jobs, isRendering, deleteRender, clearCompleted, startRender],
[
jobs,
isRendering,
loadError,
actionError,
dismissActionError,
loadRenders,
deleteRender,
cancelRender,
clearCompleted,
startRender,
],
);
}