feat: Studio bulk ops, perf fixes, bug fixes, confirm dialog visibility
CI / runtime-diagnostics (push) Skipped
CI / check (push) Successful in 34s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m3s

Confirm Dialog:
- Overlay opacity 80% -> 95% (solid black)
- Card uses bg-surface with glow shadow ring-primary/40
- Added AlertTriangle icon for danger variant
- Removed backdrop-blur for maximum opacity

Studio features:
- Bulk delete: select imported items and delete in batch
- Bulk nitro regen: select items missing .nitro and regenerate all
- Added Trash2 and RefreshCw toolbar buttons for bulk actions

Performance:
- BatchProgress: React.memo + useMemo for entries/total/completed/percent
- BatchProgress: ETA interval uses useRef to avoid re-creation per completed
- CatalogRail: React.memo + useCallback for toggle
- Removed redundant double setBatchProgress calls
- Removed redundant new Map(initial) wrapping

Bug fixes:
- importBatch: stale batchDone closure always cleared progress panel (use ref)
- importBatch: added missing toast.error on HTTP failure
- bulkRegenNitro: now checks per-item results instead of marking all as succeeded
- single deleteItem: now removes classname from selected set
- retryFailed: clears progress panel on HTTP error and stream error
- Removed unused 'done' variable (2x) from cloneAllMissing/retryFailed

Lint:
- biome format fixes for selectedMissingNitro and setItems
This commit is contained in:
openhands committed 2026-09-01 19:07:20 +02:00
1 parent 4863f78795
commit 93601e58e0
4 files changed
+214 -45

No files matched your search

+16 -8
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { AlertTriangle } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -77,7 +78,7 @@ export function ConfirmDialog({
<button <button
type="button" type="button"
aria-label="Dismiss" aria-label="Dismiss"
className="absolute inset-0 bg-black/80 backdrop-blur-sm" className="absolute inset-0 bg-black/95"
disabled={isLoading} disabled={isLoading}
onClick={() => { onClick={() => {
if (!isLoading) onOpenChange(false); if (!isLoading) onOpenChange(false);
@@ -89,19 +90,26 @@ export function ConfirmDialog({
aria-modal="true" aria-modal="true"
aria-labelledby="confirm-dialog-title" aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-desc" aria-describedby="confirm-dialog-desc"
className="relative z-10 w-full max-w-md rounded-lg border border-border bg-background/100 p-6 text-card-foreground shadow-2xl ring-1 ring-black/10 backdrop-blur-none" className="relative z-10 w-full max-w-md rounded-xl border-2 border-primary/40 bg-surface p-6 text-card-foreground shadow-[0_0_60px_-10px_rgba(245,158,11,0.3)]"
variants={modalContentVariants} variants={modalContentVariants}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
exit="exit" exit="exit"
> >
<div className="flex flex-col gap-2 text-left"> <div className="flex flex-col gap-2 text-left">
<h2 <div className="flex items-center gap-3">
id="confirm-dialog-title" {variant === "danger" && (
className="text-lg font-semibold leading-none" <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/15">
> <AlertTriangle className="h-5 w-5 text-destructive" />
{title} </div>
</h2> )}
<h2
id="confirm-dialog-title"
className="text-lg font-semibold leading-none"
>
{title}
</h2>
</div>
<p <p
id="confirm-dialog-desc" id="confirm-dialog-desc"
className="text-sm text-muted-foreground" className="text-sm text-muted-foreground"
+28 -19
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react"; import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
import { useEffect, useState } from "react"; import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -40,7 +40,7 @@ function formatEta(ms: number): string {
return `${min}m ${sec}s`; return `${min}m ${sec}s`;
} }
export function BatchProgress({ export const BatchProgress = memo(function BatchProgress({
progress, progress,
done, done,
succeeded, succeeded,
@@ -65,33 +65,42 @@ export function BatchProgress({
onDismiss?: () => void; onDismiss?: () => void;
onRetry?: (classnames: string[]) => void; onRetry?: (classnames: string[]) => void;
}) { }) {
const entries = [...progress.entries()]; const { entries, total, completed, percent } = useMemo(() => {
const total = entries.length; const e = [...progress.entries()];
const completed = entries.filter( const t = e.length;
([, s]) => s.status === "done" || s.status === "failed", const c = e.filter(
).length; ([, s]) => s.status === "done" || s.status === "failed",
const percent = total > 0 ? Math.round((completed / total) * 100) : 0; ).length;
return {
entries: e,
total: t,
completed: c,
percent: t > 0 ? Math.round((c / t) * 100) : 0,
};
}, [progress]);
// Live ETA calculation // Live ETA calculation
const [eta, setEta] = useState<string | null>(null); const [eta, setEta] = useState<string | null>(null);
const completedRef = useRef(completed);
const totalRef = useRef(total);
completedRef.current = completed;
totalRef.current = total;
useEffect(() => { useEffect(() => {
if (done || !startTime || completed === 0) { if (done || !startTime || completed === 0) {
setEta(null); setEta(null);
return; return;
} }
const elapsed = Date.now() - startTime; const calc = () => {
const avgPerItem = elapsed / completed;
const remaining = (total - completed) * avgPerItem;
setEta(formatEta(remaining));
const interval = setInterval(() => {
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
const avgPerItem = elapsed / completed; const avgPerItem = elapsed / completedRef.current;
const remaining = (total - completed) * avgPerItem; const remaining = (totalRef.current - completedRef.current) * avgPerItem;
setEta(formatEta(remaining)); setEta(formatEta(remaining));
}, 2000); };
calc();
const interval = setInterval(calc, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [done, startTime, completed, total]); }, [done, startTime, completed]);
return ( return (
<div className="border-t border-[var(--admin-border)] p-3"> <div className="border-t border-[var(--admin-border)] p-3">
@@ -244,4 +253,4 @@ export function BatchProgress({
</div> </div>
</div> </div>
); );
} });
+5 -5
View File
@@ -1,12 +1,12 @@
"use client"; "use client";
import { ChevronDown, ChevronRight, FolderTree } from "lucide-react"; import { ChevronDown, ChevronRight, FolderTree } from "lucide-react";
import { useMemo, useState } from "react"; import { memo, useCallback, useMemo, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog"; import type { TreeNode } from "@/types/catalog";
export function CatalogRail({ export const CatalogRail = memo(function CatalogRail({
tree, tree,
selectedId, selectedId,
onSelect, onSelect,
@@ -31,14 +31,14 @@ export function CatalogRail({
return tree.filter((n) => !hidden.has(n.id)); return tree.filter((n) => !hidden.has(n.id));
}, [tree, collapsed]); }, [tree, collapsed]);
const toggle = (id: number) => { const toggle = useCallback((id: number) => {
setCollapsed((prev) => { setCollapsed((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
return next; return next;
}); });
}; }, []);
return ( return (
<aside className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]"> <aside className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]">
@@ -100,4 +100,4 @@ export function CatalogRail({
</div> </div>
</aside> </aside>
); );
} });
File diff suppressed because it is too large. Load diff