11-task plan covering backend SSE progress, Python stderr emission, bridge.ts spawn rewrite, ProgressCard component, hook rewrite, and migration of all 33 tool settings components.
45 KiB
Real Progress Bars Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace all fake/missing progress indicators with real, honest progress bars backed by actual upload and server-side processing data.
Architecture: Frontend generates a client jobId and opens an SSE side-channel before uploading. Python AI scripts emit granular progress to stderr, which bridge.ts captures via spawn and forwards through the SSE system. A new ProgressCard component renders the unified progress state. Fast (Sharp-based) tools show real upload progress and an honest "Processing..." state.
Tech Stack: React 19, Fastify 5, SSE (Server-Sent Events), XMLHttpRequest (upload progress), Python stderr streaming, child_process.spawn
Spec: docs/superpowers/specs/2026-03-22-real-progress-bars-design.md
File Structure
New Files
| File | Responsibility |
|---|---|
apps/web/src/components/common/progress-card.tsx |
Card-style progress UI component |
Modified Files — Backend
| File | Change |
|---|---|
apps/api/src/routes/progress.ts |
Add SingleFileProgress type, updateSingleFileProgress() function, discriminated union |
packages/ai/src/bridge.ts |
Switch execFile → spawn, parse stderr progress JSON, venv fallback via error event |
packages/ai/src/background-removal.ts |
Accept onProgress callback, pass to bridge |
packages/ai/src/face-detection.ts |
Accept onProgress callback, pass to bridge |
packages/ai/src/upscaling.ts |
Accept onProgress callback, pass to bridge |
packages/ai/src/inpainting.ts |
Accept onProgress callback, pass to bridge |
packages/ai/src/ocr.ts |
Accept onProgress callback, pass to bridge |
packages/ai/python/remove_bg.py |
Replace sys.stderr.write() with emit_progress() JSON |
packages/ai/python/detect_faces.py |
Add emit_progress() calls |
packages/ai/python/upscale.py |
Add emit_progress() calls |
packages/ai/python/inpaint.py |
Add emit_progress() calls |
packages/ai/python/ocr.py |
Add emit_progress() calls |
apps/api/src/routes/tools/remove-background.ts |
Extract clientJobId, wire onProgress → SSE |
apps/api/src/routes/tools/upscale.ts |
Extract clientJobId, wire onProgress → SSE |
apps/api/src/routes/tools/blur-faces.ts |
Extract clientJobId, wire onProgress → SSE |
apps/api/src/routes/tools/erase-object.ts |
Extract clientJobId, wire onProgress → SSE |
apps/api/src/routes/tools/ocr.ts |
Extract clientJobId, wire onProgress → SSE |
Modified Files — Frontend
| File | Change |
|---|---|
apps/web/src/hooks/use-tool-processor.ts |
XHR upload progress, SSE for AI tools, progress state |
33 files in apps/web/src/components/tools/ |
Swap AIProgressBar/Loader2 for ProgressCard |
Deleted Files
| File | Reason |
|---|---|
apps/web/src/components/common/ai-progress-bar.tsx |
Replaced by ProgressCard |
Task 1: Extend SSE Progress System
Files:
-
Modify:
apps/api/src/routes/progress.ts -
Step 1: Add SingleFileProgress type and update function
Open apps/api/src/routes/progress.ts. Add the discriminated union types and a new updateSingleFileProgress function. The existing JobProgress becomes BatchProgress internally but we keep backward compatibility.
// Add after the existing JobProgress interface (line 20):
export interface SingleFileProgress {
jobId: string;
type: "single";
phase: "processing" | "complete" | "failed";
stage?: string;
percent: number;
error?: string;
}
/**
* Update progress for a single-file AI processing job.
* Uses the same SSE listener infrastructure as batch progress.
*/
export function updateSingleFileProgress(
progress: Omit<SingleFileProgress, "type">,
): void {
const event: SingleFileProgress = { ...progress, type: "single" };
const subs = listeners.get(progress.jobId);
if (subs) {
for (const cb of subs) {
(cb as (data: unknown) => void)(event);
}
if (progress.phase === "complete" || progress.phase === "failed") {
setTimeout(() => {
listeners.delete(progress.jobId);
}, 5000);
}
}
}
Export SingleFileProgress alongside JobProgress.
Also update the listeners map type to accept the union:
const listeners = new Map<string, Set<(data: JobProgress | SingleFileProgress) => void>>();
And update the sendEvent parameter type in the SSE route handler to accept both:
const sendEvent = (data: JobProgress | SingleFileProgress) => {
- Step 2: Verify the API server still builds
Run: cd apps/api && npx tsc --noEmit
Expected: No type errors
- Step 3: Commit
git add apps/api/src/routes/progress.ts
git commit -m "feat(api): add SingleFileProgress type and SSE update function"
Task 2: Rewrite bridge.ts to Stream Stderr Progress
Files:
-
Modify:
packages/ai/src/bridge.ts -
Step 1: Add
runPythonWithProgressusingspawn
Replace the entire contents of packages/ai/src/bridge.ts with:
import { spawn, execFile } from "node:child_process";
import { promisify } from "node:util";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
const PYTHON_DIR = resolve(__dirname, "../python");
/** Try venv first, then system python. */
function getPythonPath(): string {
const venvPath =
process.env.PYTHON_VENV_PATH || resolve(__dirname, "../../../.venv");
return `${venvPath}/bin/python3`;
}
/**
* Extract a user-friendly error from a Python process error.
* Python scripts print JSON to stderr/stdout on failure — try to parse it.
*/
function extractPythonError(error: unknown): string {
if (error && typeof error === "object") {
const execError = error as {
stderr?: string;
stdout?: string;
message?: string;
};
for (const output of [execError.stdout, execError.stderr]) {
if (output) {
try {
const parsed = JSON.parse(output.trim());
if (parsed.error) return parsed.error;
} catch {
const trimmed = output.trim();
if (trimmed && !trimmed.startsWith("Traceback")) {
return trimmed;
}
}
}
}
if (execError.message) return execError.message;
}
return String(error);
}
export interface ProgressCallback {
(percent: number, stage: string): void;
}
/**
* Run a Python script with real-time progress streaming via stderr.
* Falls back to system python3 if the venv is not available.
*
* Python scripts emit progress as JSON lines to stderr:
* {"progress": 50, "stage": "Processing..."}
*
* Non-JSON stderr lines are collected as error output (backward compatible).
*/
export function runPythonWithProgress(
scriptName: string,
args: string[],
options: {
onProgress?: ProgressCallback;
timeout?: number;
} = {},
): Promise<{ stdout: string; stderr: string }> {
const scriptPath = resolve(PYTHON_DIR, scriptName);
const timeout = options.timeout ?? 300000;
return new Promise((resolvePromise, rejectPromise) => {
const trySpawn = (pythonBin: string, isFallback: boolean) => {
const child = spawn(pythonBin, [scriptPath, ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderrLines: string[] = [];
let stderrBuffer = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeout);
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBuffer += chunk.toString();
const lines = stderrBuffer.split("\n");
// Keep the last incomplete line in the buffer
stderrBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Try to parse as progress JSON
try {
const parsed = JSON.parse(trimmed);
if (
typeof parsed.progress === "number" &&
typeof parsed.stage === "string"
) {
options.onProgress?.(parsed.progress, parsed.stage);
continue; // Don't collect progress lines as error output
}
} catch {
// Not JSON — collect as regular stderr
}
stderrLines.push(trimmed);
}
});
child.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
if (err.code === "ENOENT" && !isFallback) {
// Venv python not found — retry with system python3
trySpawn("python3", true);
} else {
rejectPromise(new Error(extractPythonError(err)));
}
});
child.on("close", (code) => {
clearTimeout(timer);
// Flush any remaining stderr buffer
if (stderrBuffer.trim()) {
stderrLines.push(stderrBuffer.trim());
}
if (timedOut) {
rejectPromise(new Error("Python script timed out"));
return;
}
const stderr = stderrLines.join("\n");
if (code !== 0) {
// Try to extract a meaningful error from stdout or stderr
const errorText =
extractPythonError({ stdout: stdout.trim(), stderr }) ||
`Python script exited with code ${code}`;
rejectPromise(new Error(errorText));
return;
}
resolvePromise({ stdout: stdout.trim(), stderr });
});
};
trySpawn(getPythonPath(), false);
});
}
/**
* Run a Python script from packages/ai/python/ with the given arguments.
* Falls back to system python3 if the venv is not available.
*
* @deprecated Use runPythonWithProgress for new code. Kept for backward compatibility.
*/
export async function runPythonScript(
scriptName: string,
args: string[],
timeoutMs = 300000,
): Promise<{ stdout: string; stderr: string }> {
return runPythonWithProgress(scriptName, args, { timeout: timeoutMs });
}
This keeps runPythonScript as a thin wrapper over runPythonWithProgress so all existing callers continue to work without changes.
- Step 2: Verify the ai package builds
Run: cd packages/ai && npx tsc --noEmit
Expected: No type errors
- Step 3: Commit
git add packages/ai/src/bridge.ts
git commit -m "feat(ai): rewrite bridge.ts to stream stderr progress via spawn"
Task 3: Add Progress Emission to Python Scripts
Files:
-
Modify:
packages/ai/python/remove_bg.py -
Modify:
packages/ai/python/detect_faces.py -
Modify:
packages/ai/python/upscale.py -
Modify:
packages/ai/python/inpaint.py -
Modify:
packages/ai/python/ocr.py -
Step 1: Update
remove_bg.py
Add emit_progress helper and replace existing sys.stderr.write() calls. Note: the os.dup2(2, 1) redirect sends library noise to stderr — this is fine because bridge.ts ignores non-JSON stderr lines.
Add at the top of the file (after imports):
def emit_progress(percent, stage):
"""Emit structured progress to stderr for bridge.ts to capture."""
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
In main(), replace the two sys.stderr.write blocks:
- Replace
sys.stderr.write(f"Loading model: {model}\n")/sys.stderr.flush()(lines 24-25) with:emit_progress(10, "Loading model") - Replace
sys.stderr.write("Processing image...\n")/sys.stderr.flush()(lines 29-30) with:emit_progress(25, "Model loaded")
Add new progress calls:
-
Before
output_data = remove(...)(line 37):emit_progress(30, "Analyzing image") -
After
remove()completes (after the try/except block, ~line 45):emit_progress(80, "Background removed") -
Before
if bg_colorcheck (line 48):emit_progress(85, "Compositing background")(only if bg_color is set) -
Before
with open(output_path, "wb")(line 62):emit_progress(95, "Saving result") -
Step 2: Update
detect_faces.py
Add emit_progress helper at the top (after imports):
def emit_progress(percent, stage):
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
Add calls at these points in main():
-
Before
from PIL import Image(line 15):emit_progress(10, "Loading face detection model") -
After MediaPipe imports succeed (line 22):
emit_progress(20, "Model ready") -
Before
results = detector.process(img_array)(line 29):emit_progress(25, "Scanning for faces") -
After detection, before the loop (line 32):
emit_progress(50, f"Found {len(results.detections or [])} faces") -
Inside the face loop, after each blur (line 51):
emit_progress(50 + int((i + 1) / len(results.detections) * 40), f"Blurring face {i + 1} of {len(results.detections)}")— addenumerateto theforloop -
Before
img.save(output_path)(line 54):emit_progress(95, "Saving result") -
Step 3: Update
upscale.py
Add emit_progress helper at the top (after imports):
def emit_progress(percent, stage):
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
Add calls:
-
Before
from PIL import Image(line 14):emit_progress(10, "Loading upscale model") -
Before Real-ESRGAN import attempt (line 21):
emit_progress(15, "Checking for Real-ESRGAN") -
After
RealESRGANercreation (line 38):emit_progress(20, "Model ready") -
Before
upsampler.enhance(line 40):emit_progress(25, "Upscaling image") -
After
enhancereturns (line 41):emit_progress(90, "Upscaling complete") -
In the Lanczos fallback, before
img.resize(line 46):emit_progress(50, "Upscaling with Lanczos") -
Before the final
print(json.dumps(...))(line 50):emit_progress(95, "Saving result") -
Step 4: Update
inpaint.py
Add emit_progress helper at the top (after imports):
def emit_progress(percent, stage):
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
Add calls:
-
Before
from PIL import Image(line 12):emit_progress(10, "Loading inpainting model") -
After LaMa imports succeed (line 17):
emit_progress(20, "Model loaded") -
Before mask resize check (line 23):
emit_progress(25, "Analyzing mask") -
Before
model_manager(...)call (line 40):emit_progress(40, "Inpainting region") -
After result returns (line 41):
emit_progress(85, "Refining edges") -
Before
Image.fromarray(result).save(...)(line 41):emit_progress(95, "Saving result") -
Step 5: Update
ocr.py
Add emit_progress helper at the top (after imports):
def emit_progress(percent, stage):
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
Add calls:
-
At the start of the main try block (line 13):
emit_progress(10, "Loading OCR engine") -
For PaddleOCR path, before
ocr.ocr(...)(line 19):emit_progress(30, "Analyzing text regions") -
After
ocr.ocr(...)returns (line 19):emit_progress(70, "Extracting text") -
For Tesseract path, before
subprocess.run(...)(line 50):emit_progress(30, "Running Tesseract") -
After
subprocess.run(...)returns (line 55):emit_progress(70, "Extracting text") -
Before the final
print(json.dumps(...))calls:emit_progress(95, "Formatting results") -
Step 6: Commit
git add packages/ai/python/remove_bg.py packages/ai/python/detect_faces.py packages/ai/python/upscale.py packages/ai/python/inpaint.py packages/ai/python/ocr.py
git commit -m "feat(ai): add emit_progress() calls to all Python AI scripts"
Task 4: Wire Progress Callbacks Through AI Wrappers
Files:
- Modify:
packages/ai/src/background-removal.ts - Modify:
packages/ai/src/face-detection.ts - Modify:
packages/ai/src/upscaling.ts - Modify:
packages/ai/src/inpainting.ts - Modify:
packages/ai/src/ocr.ts
Each AI wrapper currently calls runPythonScript(). Change them to call runPythonWithProgress() and accept an optional onProgress callback.
- Step 1: Update
background-removal.ts
Change import from runPythonScript to runPythonWithProgress and add ProgressCallback type import:
import { runPythonWithProgress, type ProgressCallback } from "./bridge.js";
Add onProgress to the function signature:
export async function removeBackground(
inputBuffer: Buffer,
outputDir: string,
options: RemoveBackgroundOptions = {},
onProgress?: ProgressCallback,
): Promise<Buffer> {
Replace the runPythonScript call (line 25-29):
const timeout = options.model?.startsWith("birefnet") ? 600000 : 300000;
const { stdout } = await runPythonWithProgress("remove_bg.py", [
inputPath,
outputPath,
JSON.stringify(options),
], { onProgress, timeout });
- Step 2: Update
face-detection.ts
Same pattern. Change import, add onProgress parameter:
import { runPythonWithProgress, type ProgressCallback } from "./bridge.js";
export async function blurFaces(
inputBuffer: Buffer,
outputDir: string,
options: BlurFacesOptions = {},
onProgress?: ProgressCallback,
): Promise<BlurFacesResult> {
Replace runPythonScript call (line 32-36):
const { stdout } = await runPythonWithProgress("detect_faces.py", [
inputPath,
outputPath,
JSON.stringify(options),
], { onProgress });
- Step 3: Update
upscaling.ts
Same pattern:
import { runPythonWithProgress, type ProgressCallback } from "./bridge.js";
export async function upscale(
inputBuffer: Buffer,
outputDir: string,
options: UpscaleOptions = {},
onProgress?: ProgressCallback,
): Promise<UpscaleResult> {
Replace runPythonScript call (line 25-29):
const { stdout } = await runPythonWithProgress("upscale.py", [
inputPath,
outputPath,
JSON.stringify(options),
], { onProgress });
- Step 4: Update
inpainting.ts
Same pattern:
import { runPythonWithProgress, type ProgressCallback } from "./bridge.js";
export async function inpaint(
inputBuffer: Buffer,
maskBuffer: Buffer,
outputDir: string,
onProgress?: ProgressCallback,
): Promise<Buffer> {
Replace runPythonScript call (line 17-21):
const { stdout } = await runPythonWithProgress("inpaint.py", [
inputPath,
maskPath,
outputPath,
], { onProgress });
- Step 5: Update
ocr.ts
Same pattern:
import { runPythonWithProgress, type ProgressCallback } from "./bridge.js";
export async function extractText(
inputBuffer: Buffer,
outputDir: string,
options: OcrOptions = {},
onProgress?: ProgressCallback,
): Promise<OcrResult> {
Replace runPythonScript call (line 23-26):
const { stdout } = await runPythonWithProgress("ocr.py", [
inputPath,
JSON.stringify(options),
], { onProgress });
- Step 6: Export
ProgressCallbackfrom index.ts
Add to packages/ai/src/index.ts:
export { runPythonWithProgress } from "./bridge.js";
export type { ProgressCallback } from "./bridge.js";
(Keep the existing runPythonScript export too.)
- Step 7: Verify the ai package builds
Run: cd packages/ai && npx tsc --noEmit
Expected: No type errors
- Step 8: Commit
git add packages/ai/src/background-removal.ts packages/ai/src/face-detection.ts packages/ai/src/upscaling.ts packages/ai/src/inpainting.ts packages/ai/src/ocr.ts packages/ai/src/index.ts
git commit -m "feat(ai): add onProgress callback to all AI wrapper functions"
Task 5: Wire AI Route Handlers to SSE Progress
Files:
- Modify:
apps/api/src/routes/tools/remove-background.ts - Modify:
apps/api/src/routes/tools/upscale.ts - Modify:
apps/api/src/routes/tools/blur-faces.ts - Modify:
apps/api/src/routes/tools/erase-object.ts - Modify:
apps/api/src/routes/tools/ocr.ts
Each route handler needs to: (1) extract clientJobId from the multipart form, (2) create an onProgress callback that calls updateSingleFileProgress, (3) pass it to the AI wrapper.
- Step 1: Update
remove-background.ts
Add import:
import { updateSingleFileProgress } from "../progress.js";
In the multipart parsing loop (line 20-33), add a case for clientJobId:
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
Declare let clientJobId: string | null = null; alongside the other variables (line 16-18).
Create the progress callback before calling removeBackground (around line 55):
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
Pass it to removeBackground (line 55-58):
const resultBuffer = await removeBackground(
fileBuffer,
join(workspacePath, "output"),
{ model: settings.model, backgroundColor: settings.backgroundColor },
onProgress,
);
After processing completes, emit completion:
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
- Step 2: Update
upscale.ts
Same pattern as above. Add updateSingleFileProgress import, extract clientJobId from multipart, create callback, pass to upscale():
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await upscale(
fileBuffer,
join(workspacePath, "output"),
{ scale },
onProgress,
);
- Step 3: Update
blur-faces.ts
Same pattern. Pass callback to blurFaces():
const result = await blurFaces(
fileBuffer,
join(workspacePath, "output"),
{
blurRadius: settings.blurRadius ?? 30,
sensitivity: settings.sensitivity ?? 0.5,
},
onProgress,
);
- Step 4: Update
erase-object.ts
Same pattern but also extract clientJobId from multipart. Note: erase-object has a different multipart structure (file + mask + no settings). Add clientJobId extraction in the parts loop.
- Step 5: Update
ocr.ts
Same pattern. Pass callback to extractText().
- Step 6: Verify the API server builds
Run: cd apps/api && npx tsc --noEmit
Expected: No type errors
- Step 7: Commit
git add apps/api/src/routes/tools/remove-background.ts apps/api/src/routes/tools/upscale.ts apps/api/src/routes/tools/blur-faces.ts apps/api/src/routes/tools/erase-object.ts apps/api/src/routes/tools/ocr.ts
git commit -m "feat(api): wire AI route handlers to SSE progress via clientJobId"
Task 6: Build the ProgressCard Component
Files:
-
Create:
apps/web/src/components/common/progress-card.tsx -
Step 1: Create the ProgressCard component
import { Upload, Loader2 } from "lucide-react";
interface ProgressCardProps {
active: boolean;
phase: "uploading" | "processing" | "complete";
label: string;
stage?: string;
percent: number;
elapsed: number;
}
export function ProgressCard({
active,
phase,
label,
stage,
percent,
elapsed,
}: ProgressCardProps) {
if (!active) return null;
const icon =
phase === "uploading" ? (
<Upload className="h-4 w-4 text-primary" />
) : (
<Loader2 className="h-4 w-4 text-primary animate-spin" />
);
const sublabel = [stage, `${elapsed}s`].filter(Boolean).join(" \u00b7 ");
return (
<div className="bg-muted/80 border border-border rounded-xl p-3 space-y-2.5">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{label}
</div>
<div className="text-[11px] text-muted-foreground truncate">
{sublabel}
</div>
</div>
<span className="text-sm font-semibold text-primary font-mono tabular-nums">
{Math.round(percent)}%
</span>
</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"
style={{ width: `${Math.min(100, percent)}%` }}
/>
</div>
</div>
);
}
- Step 2: Verify the web app builds
Run: cd apps/web && npx tsc --noEmit
Expected: No type errors
- Step 3: Commit
git add apps/web/src/components/common/progress-card.tsx
git commit -m "feat(web): add ProgressCard component"
Task 7: Rewrite useToolProcessor Hook
Files:
-
Modify:
apps/web/src/hooks/use-tool-processor.ts -
Step 1: Rewrite with XHR upload progress and SSE
Replace the entire contents of apps/web/src/hooks/use-tool-processor.ts:
import { useCallback, useState, useRef, useEffect } from "react";
import { useFileStore } from "@/stores/file-store";
import { TOOLS } from "@stirling-image/shared";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
interface ProcessResult {
jobId: string;
downloadUrl: string;
originalSize: number;
processedSize: number;
}
export interface ToolProgress {
phase: "idle" | "uploading" | "processing" | "complete";
percent: number;
stage?: string;
elapsed: number;
}
const IDLE_PROGRESS: ToolProgress = {
phase: "idle",
percent: 0,
elapsed: 0,
};
export function useToolProcessor(toolId: string) {
const {
processing,
error,
processedUrl,
originalSize,
processedSize,
setProcessing,
setError,
setProcessedUrl,
setSizes,
setJobId,
} = useFileStore();
const [progress, setProgress] = useState<ToolProgress>(IDLE_PROGRESS);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
// AI tools that go through Python/bridge.ts and can emit SSE progress.
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
const AI_PYTHON_TOOLS = new Set([
"remove-background", "upscale", "blur-faces", "erase-object", "ocr",
]);
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
// Clean up on unmount
useEffect(() => {
return () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (eventSourceRef.current) eventSourceRef.current.close();
if (xhrRef.current) xhrRef.current.abort();
};
}, []);
const processFiles = useCallback(
(files: File[], settings: Record<string, unknown>) => {
if (files.length === 0) {
setError("No files selected");
return;
}
setProcessing(true);
setError(null);
setProcessedUrl(null);
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
// Start elapsed timer
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setProgress((prev) => ({
...prev,
elapsed: Math.floor((Date.now() - startTime) / 1000),
}));
}, 1000);
// Generate client job ID for SSE correlation
const clientJobId = crypto.randomUUID();
// For AI tools, open SSE before uploading
if (isAiTool) {
try {
const es = new EventSource(
`/api/v1/jobs/${clientJobId}/progress`,
);
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "single" && typeof data.percent === "number") {
setProgress((prev) => ({
...prev,
phase: "processing",
percent: data.percent,
stage: data.stage,
}));
}
} catch {
// Ignore malformed SSE
}
};
es.onerror = () => {
// SSE failed — continue without server progress.
// The upload/response flow still works.
es.close();
eventSourceRef.current = null;
};
} catch {
// EventSource creation failed — proceed without SSE
}
}
// Build form data
const formData = new FormData();
formData.append("file", files[0]);
formData.append("settings", JSON.stringify(settings));
if (isAiTool) {
formData.append("clientJobId", clientJobId);
}
// Use XHR for upload progress tracking
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const uploadPercent = (event.loaded / event.total) * 100;
setProgress((prev) => {
// Only update if we're still in upload phase
if (prev.phase !== "uploading") return prev;
return { ...prev, percent: uploadPercent };
});
}
};
xhr.upload.onload = () => {
// Upload complete — transition to processing phase
setProgress((prev) => ({
...prev,
phase: "processing",
percent: isAiTool ? 0 : 100, // AI tools reset to 0 (SSE will drive), fast tools stay at 100
stage: isAiTool ? "Starting..." : "Processing...",
}));
};
xhr.onload = () => {
// Clean up
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result: ProcessResult = JSON.parse(xhr.responseText);
setJobId(result.jobId);
setProcessedUrl(result.downloadUrl);
setSizes(result.originalSize, result.processedSize);
} catch {
setError("Invalid response from server");
}
} else {
try {
const body = JSON.parse(xhr.responseText);
setError(body.error || body.details || `Processing failed: ${xhr.status}`);
} catch {
setError(`Processing failed: ${xhr.status}`);
}
}
setProcessing(false);
setProgress(IDLE_PROGRESS);
};
xhr.onerror = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setError("Network error — check your connection");
setProcessing(false);
setProgress(IDLE_PROGRESS);
};
xhr.open("POST", `/api/v1/tools/${toolId}`);
const token = getToken();
if (token) {
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
}
xhr.send(formData);
},
[toolId, isAiTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
);
return {
processFiles,
processing,
error,
downloadUrl: processedUrl,
originalSize,
processedSize,
progress,
};
}
- Step 2: Verify the web app builds
Run: cd apps/web && npx tsc --noEmit
Expected: No type errors
- Step 3: Commit
git add apps/web/src/hooks/use-tool-processor.ts
git commit -m "feat(web): rewrite useToolProcessor with XHR upload progress and SSE"
Task 8: Migrate AI Tool Settings Components (5 files)
These components are AI tools. 4 currently import AIProgressBar, and ocr-settings.tsx uses custom fetch (like erase-object). Replace all with ProgressCard.
Files:
-
Modify:
apps/web/src/components/tools/remove-bg-settings.tsx -
Modify:
apps/web/src/components/tools/blur-faces-settings.tsx -
Modify:
apps/web/src/components/tools/upscale-settings.tsx -
Modify:
apps/web/src/components/tools/erase-object-settings.tsx -
Modify:
apps/web/src/components/tools/ocr-settings.tsx -
Step 1: Update
remove-bg-settings.tsx
Remove the AIProgressBar import (line 4). Add the ProgressCard import:
import { ProgressCard } from "@/components/common/progress-card";
Destructure progress from the hook (line 35-36):
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("remove-background");
Replace the process button + AIProgressBar block (lines 131-149). The ProgressCard replaces the button when active:
{/* Process button / Progress */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Removing background"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Remove Background
</button>
)}
Remove the Loader2 import if no longer used.
- Step 2: Update
blur-faces-settings.tsx
Same pattern. Replace AIProgressBar import with ProgressCard. Destructure progress. Replace button+AIProgressBar with:
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Blurring faces"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Blur Faces
</button>
)}
- Step 3: Update
upscale-settings.tsx
Same pattern. Label: "Upscaling image". Button text: `Upscale ${scale}x`.
- Step 4: Update
erase-object-settings.tsx
This component uses its own fetch call instead of useToolProcessor. It needs a bigger refactor:
- Replace the manual
fetchwithuseToolProcessor("erase-object")— but note erase-object sends amaskfile alongside the main file. The currentuseToolProcessoronly sends one file.
For now, keep the custom fetch but add a local progress state that tracks upload progress via XHR. Replace the fetch call (lines 35-39) with an XHR-based approach similar to useToolProcessor. Add ProgressCard with label "Erasing object".
Replace AIProgressBar import with ProgressCard. Add local progress state and rewrite the handleProcess function to use XHR + SSE:
import { ProgressCard } from "@/components/common/progress-card";
// Inside the component, add state:
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
const [progressPercent, setProgressPercent] = useState(0);
const [progressStage, setProgressStage] = useState<string | undefined>();
const [elapsed, setElapsed] = useState(0);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Rewrite handleProcess to use XHR:
const handleProcess = () => {
if (files.length === 0 || !maskFile) return;
setProcessing(true);
setError(null);
setDownloadUrl(null);
setProgressPhase("uploading");
setProgressPercent(0);
setElapsed(0);
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
const clientJobId = crypto.randomUUID();
// Open SSE for server-side progress
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "single" && typeof data.percent === "number") {
setProgressPhase("processing");
setProgressPercent(data.percent);
setProgressStage(data.stage);
}
} catch {}
};
es.onerror = () => es.close();
const formData = new FormData();
formData.append("file", files[0]);
formData.append("mask", maskFile);
formData.append("clientJobId", clientJobId);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setProgressPercent((e.loaded / e.total) * 100);
};
xhr.upload.onload = () => {
setProgressPhase("processing");
setProgressPercent(0);
setProgressStage("Starting...");
};
xhr.onload = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
if (xhr.status >= 200 && xhr.status < 300) {
const data = JSON.parse(xhr.responseText);
setDownloadUrl(data.downloadUrl);
setOriginalSize(data.originalSize);
setProcessedSize(data.processedSize);
} else {
const body = JSON.parse(xhr.responseText);
setError(body.error || body.details || `Failed: ${xhr.status}`);
}
setProcessing(false);
setProgressPhase("idle");
};
xhr.onerror = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
setError("Network error");
setProcessing(false);
setProgressPhase("idle");
};
xhr.open("POST", "/api/v1/tools/erase-object");
xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);
xhr.send(formData);
};
Replace the button + AIProgressBar with:
{processing ? (
<ProgressCard
active={processing}
phase={progressPhase === "idle" ? "uploading" : progressPhase}
label="Erasing object"
stage={progressStage}
percent={progressPercent}
elapsed={elapsed}
/>
) : (
<button
onClick={handleProcess}
disabled={!hasFile || !maskFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Erase Object
</button>
)}
- Step 5: Update
ocr-settings.tsx
ocr-settings.tsx also uses custom fetch (not useToolProcessor). Apply the same XHR + SSE pattern as erase-object above, with label "Extracting text". Extract clientJobId from multipart, open SSE, track upload progress.
- Step 6: Verify the web app builds
Run: cd apps/web && npx tsc --noEmit
Expected: No type errors
- Step 6: Commit
git add apps/web/src/components/tools/remove-bg-settings.tsx apps/web/src/components/tools/blur-faces-settings.tsx apps/web/src/components/tools/upscale-settings.tsx apps/web/src/components/tools/erase-object-settings.tsx apps/web/src/components/tools/ocr-settings.tsx
git commit -m "feat(web): migrate AI tool settings to ProgressCard"
Task 9: Migrate Non-AI Tool Settings Components
Two groups of components need migration:
Group A — 11 files using useToolProcessor (straightforward):
color-settings.tsx, crop-settings.tsx, rotate-settings.tsx, compress-settings.tsx, resize-settings.tsx, strip-metadata-settings.tsx, convert-settings.tsx, smart-crop-settings.tsx, text-overlay-settings.tsx, watermark-text-settings.tsx, replace-color-settings.tsx, gif-tools-settings.tsx, border-settings.tsx
Pattern: destructure progress from useToolProcessor, replace button+Loader2 with ProgressCard.
Group B — 17 files using custom fetch (skip or defer):
collage-settings.tsx, compose-settings.tsx, favicon-settings.tsx, image-to-pdf-settings.tsx, vectorize-settings.tsx, split-settings.tsx, svg-to-raster-settings.tsx, watermark-image-settings.tsx, bulk-rename-settings.tsx, find-duplicates-settings.tsx, color-palette-settings.tsx, barcode-read-settings.tsx, qr-generate-settings.tsx, info-settings.tsx, compare-settings.tsx
These do not use useToolProcessor. Either refactor them to use useToolProcessor first, or add inline XHR progress tracking. For v1, skip these — they are fast tools where the existing spinner is acceptable. Migrate them in a follow-up pass.
- Step 1: Update Group A tool settings components (11 files)
For each Group A file, apply this transformation:
Add import:
import { ProgressCard } from "@/components/common/progress-card";
Destructure progress:
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("TOOL_ID");
Replace button block:
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="TOOL_LABEL"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
BUTTON_TEXT
</button>
)}
Tool labels for Group A (use the action name, not the tool name):
| File | label | button text |
|---|---|---|
| resize-settings.tsx | "Resizing" | "Resize" |
| crop-settings.tsx | "Cropping" | "Crop" |
| rotate-settings.tsx | "Rotating" | "Rotate" |
| convert-settings.tsx | "Converting" | "Convert" |
| compress-settings.tsx | "Compressing" | "Compress" |
| strip-metadata-settings.tsx | "Stripping metadata" | "Strip Metadata" |
| color-settings.tsx | "Adjusting colors" | "Apply" |
| replace-color-settings.tsx | "Replacing color" | "Replace Color" |
| border-settings.tsx | "Adding border" | "Add Border" |
| watermark-text-settings.tsx | "Adding watermark" | "Add Watermark" |
| text-overlay-settings.tsx | "Adding text" | "Add Text" |
| smart-crop-settings.tsx | "Smart cropping" | "Smart Crop" |
| gif-tools-settings.tsx | "Processing GIF" | "Process" |
- Step 2: Verify the web app builds
Run: cd apps/web && npx tsc --noEmit
Expected: No type errors
- Step 3: Commit
git add apps/web/src/components/tools/
git commit -m "feat(web): add ProgressCard to non-AI tool settings (Group A)"
Task 10: Delete Old AIProgressBar and Clean Up
Files:
-
Delete:
apps/web/src/components/common/ai-progress-bar.tsx -
Step 1: Verify no remaining imports of AIProgressBar
Search for any remaining references:
grep -r "AIProgressBar\|ai-progress-bar" apps/web/src/
Expected: No results. If any remain, update those files first.
- Step 2: Delete the file
rm apps/web/src/components/common/ai-progress-bar.tsx
- Step 3: Verify the web app builds
Run: cd apps/web && npx tsc --noEmit
Expected: No type errors
- Step 4: Commit
git add -A apps/web/src/components/common/ai-progress-bar.tsx
git commit -m "chore(web): delete old AIProgressBar component"
Task 11: End-to-End Verification
- Step 1: Build the full project
Run: pnpm build
Expected: All packages build successfully
- Step 2: Start the dev server and test
Run: pnpm dev
Manual testing checklist:
- Fast tool (resize): Upload a large image (>5MB). Verify you see upload progress in the ProgressCard. Verify it transitions to "Processing..." briefly, then disappears and shows the download button.
- AI tool (remove-background): Upload an image. Verify you see upload progress, then real server-side stages (Loading model → Model loaded → Analyzing image → Background removed → Saving result) with percentage updates.
- Small file fast tool: Upload a tiny image to compress. Verify the progress card flashes briefly and doesn't get stuck.
- Error case: Try to process without uploading a file. Verify error message appears correctly.
- Step 3: Commit any fixes from testing