/** * @file uploadJobs.ts * @description The app-wide list of File Explorer uploads, shown by * `components/BackgroundJobsDock.tsx` so an upload's progress outlives the * Files page. The upload loop itself stays in `FileExplorer.tsx`; it reports * here (`beginUpload` → `update` → `end`) and checks the job's AbortSignal, * which **Stop** (`stopUpload`) fires. The bytes live in the browser, so an * upload cannot survive a reload: while one runs, a small record of it is * kept in localStorage (refreshed every ~2 s), and the next page load shows * a record that stopped being refreshed as an interrupted upload until it is * dismissed. A fresh record belongs to another open tab and is left alone. * A dashboard RESTART is not an interruption: the upload is sent in parts * (`api.files.uploadStaged`) and the job shows `waiting` until it resumes. * @author Nguyễn Ngọc Trí Vĩ */ import { useSyncExternalStore } from "react"; export interface UploadJob { id: string; /** Where the files go, as the user reads it ("workspace/folder"). */ folder: string; /** The file (or batch) being sent now. */ name: string; done: number; total: number; sent: number; bytes: number; state: "running" | "stopping" | "interrupted"; /** The dashboard is away (a restart): the upload waits and then resumes. */ waiting?: boolean; } type Stored = UploadJob & { beat: number }; const KEY = "agenthub.uploads.inflight"; const BEAT_MS = 2000; /** A running record older than this is from a page that is gone. */ const STALE_MS = 6000; const listeners = new Set<() => void>(); const controllers = new Map(); /** Ids this page owns in storage (its own uploads and the interrupted ones it shows). */ const mine = new Set(); let uploads: UploadJob[] = []; let beat: ReturnType | undefined; let lastWrite = 0; const emit = () => listeners.forEach((l) => l()); function readStored(): Stored[] { try { const v: unknown = JSON.parse(localStorage.getItem(KEY) || "[]"); return Array.isArray(v) ? (v as Stored[]).filter((r) => r && typeof r.id === "string") : []; } catch { return []; } } function persist() { lastWrite = Date.now(); try { const others = readStored().filter((r) => !mine.has(r.id)); const own = uploads.map((u) => ({ ...u, beat: lastWrite })); const all = [...others, ...own]; if (all.length) localStorage.setItem(KEY, JSON.stringify(all)); else localStorage.removeItem(KEY); } catch { /* storage off: the dock still works for this page */ } } /** Load what a previous page left behind: a stale running record = interrupted. */ function loadInterrupted() { const now = Date.now(); const left = readStored().filter( (r) => r.state === "interrupted" || now - (Number(r.beat) || 0) > STALE_MS ); for (const r of left) mine.add(r.id); uploads = left.map((r) => ({ id: r.id, folder: String(r.folder ?? ""), name: String(r.name ?? ""), done: Number(r.done) || 0, total: Number(r.total) || 0, sent: Number(r.sent) || 0, bytes: Number(r.bytes) || 0, state: "interrupted", })); if (left.length) persist(); } loadInterrupted(); function set(id: string, patch: Partial) { uploads = uploads.map((u) => (u.id === id ? { ...u, ...patch } : u)); emit(); if (Date.now() - lastWrite > 1000) persist(); } function heartbeat() { const running = uploads.some((u) => u.state !== "interrupted"); if (running && !beat) beat = setInterval(persist, BEAT_MS); if (!running && beat) { clearInterval(beat); beat = undefined; } } /** Register an upload; the loop reports through the handle and checks `signal`. */ export function beginUpload(init: { folder: string; total: number; bytes: number }) { const id = `up-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const ctrl = new AbortController(); controllers.set(id, ctrl); mine.add(id); uploads = [...uploads, { id, name: "", done: 0, sent: 0, state: "running", ...init }]; emit(); persist(); heartbeat(); return { id, signal: ctrl.signal, update: (patch: Partial>) => set(id, patch), end: () => { controllers.delete(id); uploads = uploads.filter((u) => u.id !== id); emit(); persist(); mine.delete(id); heartbeat(); }, }; } /** Stop a running upload: the request in flight is dropped, the loop ends. */ export function stopUpload(id: string): void { const ctrl = controllers.get(id); if (!ctrl) return; set(id, { state: "stopping" }); ctrl.abort(); } /** Forget an interrupted upload's record. */ export function dismissUpload(id: string): void { uploads = uploads.filter((u) => u.id !== id); emit(); persist(); mine.delete(id); } function subscribe(l: () => void) { listeners.add(l); return () => { listeners.delete(l); }; } export function useUploadJobs(): UploadJob[] { return useSyncExternalStore(subscribe, () => uploads); } /** Tests only: re-read storage as a fresh page load would. */ export function reloadUploadsForTests(): void { for (const c of controllers.values()) c.abort(); controllers.clear(); mine.clear(); if (beat) clearInterval(beat); beat = undefined; lastWrite = 0; loadInterrupted(); emit(); }