fix: batch SSE progress and non-AI processing UX (#24)

Batch progress was broken because JobProgress events lacked a `type`
field. The frontend checks `data.type === "batch"` to distinguish batch
from single-file SSE events, so batch progress was silently discarded
and multi-file processing appeared stuck at 15%.

Also improves the processing UX for non-AI (Sharp-based) tools: the
progress bar now pulses during the server processing phase and shows
a "This may take a moment" hint after 10 seconds.

Co-authored-by: Siddharth Kumar Sah <siddharth123sk@gmail.com>
This commit is contained in:
stirling-image
2026-04-06 21:25:00 +08:00
committed by GitHub
co-authored by Siddharth Kumar Sah
parent 3018137548
commit dc70cdbdd5
2 changed files with 12 additions and 5 deletions
+6 -3
View File
@@ -14,6 +14,7 @@ import { db, schema } from "../db/index.js";
export interface JobProgress {
jobId: string;
type?: "batch";
status: "processing" | "completed" | "failed";
totalFiles: number;
completedFiles: number;
@@ -161,11 +162,13 @@ export function recoverStaleJobs(): void {
export function updateJobProgress(progress: JobProgress): void {
jobProgressStore.set(progress.jobId, progress);
persistJobProgress(progress);
// Notify all SSE listeners
// Notify all SSE listeners (add type: "batch" so the frontend can distinguish
// batch events from single-file events in the shared SSE stream)
const subs = listeners.get(progress.jobId);
if (subs) {
const event = { ...progress, type: "batch" } as JobProgress & { type: "batch" };
for (const cb of subs) {
cb(progress);
cb(event);
}
// If the job is done, clean up listeners after a brief delay
if (progress.status === "completed" || progress.status === "failed") {
@@ -218,7 +221,7 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
// If the job already has progress, send it immediately
const existing = jobProgressStore.get(jobId);
if (existing) {
sendEvent(existing);
sendEvent({ ...existing, type: "batch" });
if (existing.status === "completed" || existing.status === "failed") {
reply.raw.end();
return;
@@ -12,6 +12,9 @@ interface ProgressCardProps {
export function ProgressCard({ active, phase, label, stage, percent, elapsed }: ProgressCardProps) {
if (!active) return null;
// No real-time server progress: non-AI tools sit at 100% while the server works
const isIndeterminate = phase === "processing" && percent >= 100;
const icon =
phase === "uploading" ? (
<Upload className="h-4 w-4 text-primary" />
@@ -19,7 +22,8 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }:
<Loader2 className="h-4 w-4 text-primary animate-spin" />
);
const sublabel = [stage, `${elapsed}s`].filter(Boolean).join(" \u00b7 ");
const slowHint = phase === "processing" && elapsed >= 10 ? "This may take a moment" : undefined;
const sublabel = [stage, slowHint, `${elapsed}s`].filter(Boolean).join(" \u00b7 ");
return (
<div className="bg-muted/80 border border-border rounded-xl p-3 space-y-2.5">
@@ -37,7 +41,7 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }:
</div>
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500 ease-out"
className={`h-full bg-primary rounded-full transition-all duration-500 ease-out ${isIndeterminate ? "animate-pulse" : ""}`}
style={{ width: `${Math.min(100, percent)}%` }}
/>
</div>