Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { page } from "$app/state";
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "$lib/api";
|
||||
import { unwrapList } from "$lib/list";
|
||||
import { i18n } from "$lib/i18n";
|
||||
import {
|
||||
formatJobErrorText,
|
||||
formatJobStatusLabel,
|
||||
formatProcessingTypeLabel,
|
||||
isActiveProcessingJob
|
||||
} from "$lib/job-status";
|
||||
import { notifyError, notifySuccess } from "$lib/notify";
|
||||
import type { ListResponse, ProcessingJob } from "$lib/types";
|
||||
import { Badge, Progress, buttonClasses } from "$lib/components/ui";
|
||||
import { ChevronDown, ChevronUp, Layers } from "@lucide/svelte";
|
||||
|
||||
const POLL_MS = 2_000;
|
||||
const RETRY_MS = 10_000;
|
||||
/** Keep the float visible briefly after the last active job finishes (UX + short E2E windows). */
|
||||
const LINGER_MS = 6_000;
|
||||
|
||||
let jobs = $state<ProcessingJob[]>([]);
|
||||
let displayJobs = $state<ProcessingJob[]>([]);
|
||||
let collapsed = $state(false);
|
||||
let timer: number | undefined;
|
||||
let lingerTimer: number | undefined;
|
||||
let abort: AbortController | null = null;
|
||||
let gen = 0;
|
||||
/** Jobs observed as active this session — terminal toasts only for transitions we watched. */
|
||||
const watchedActiveIds = new Set<string>();
|
||||
const toastedTerminalIds = new Set<string>();
|
||||
|
||||
const activeJobs = $derived(jobs.filter((j) => isActiveProcessingJob(j.status)));
|
||||
const visible = $derived(
|
||||
displayJobs.length > 0 && !page.url.pathname.startsWith("/processing")
|
||||
);
|
||||
const headline = $derived(
|
||||
displayJobs.length === 1
|
||||
? i18n.t("taskIndicator.activeOne")
|
||||
: i18n.t("taskIndicator.activeMany", {
|
||||
count: Math.max(displayJobs.length, activeJobs.length)
|
||||
})
|
||||
);
|
||||
const previewJobs = $derived(displayJobs.slice(0, 3));
|
||||
|
||||
function isTerminalSuccess(status: string | null | undefined): boolean {
|
||||
const s = (status ?? "").toLowerCase().trim();
|
||||
return s === "completed" || s === "success" || s === "done";
|
||||
}
|
||||
|
||||
function isTerminalFailure(status: string | null | undefined): boolean {
|
||||
const s = (status ?? "").toLowerCase().trim();
|
||||
return s === "failed" || s === "error";
|
||||
}
|
||||
|
||||
function isTerminalCancelled(status: string | null | undefined): boolean {
|
||||
const s = (status ?? "").toLowerCase().trim();
|
||||
return s === "cancelled" || s === "canceled";
|
||||
}
|
||||
|
||||
/** Fire ai_done / ai_fail toasts when a watched active job reaches a terminal status. */
|
||||
function notifyTerminalTransitions(nextJobs: ProcessingJob[]) {
|
||||
for (const job of nextJobs) {
|
||||
const id = String(job.id ?? "").trim();
|
||||
if (!id) continue;
|
||||
if (isActiveProcessingJob(job.status)) {
|
||||
watchedActiveIds.add(id);
|
||||
continue;
|
||||
}
|
||||
if (!watchedActiveIds.has(id) || toastedTerminalIds.has(id)) {
|
||||
if (isTerminalCancelled(job.status) || isTerminalSuccess(job.status) || isTerminalFailure(job.status)) {
|
||||
watchedActiveIds.delete(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
watchedActiveIds.delete(id);
|
||||
if (isTerminalCancelled(job.status)) continue;
|
||||
toastedTerminalIds.add(id);
|
||||
const typeLabel = formatProcessingTypeLabel(job.processing_type ?? job.type);
|
||||
const viewAction = { label: i18n.t("taskIndicator.view"), href: "/processing" };
|
||||
if (isTerminalSuccess(job.status)) {
|
||||
notifySuccess(i18n.t("toast.processing.jobCompleted"), {
|
||||
alert: "ai_done",
|
||||
description: typeLabel,
|
||||
actions: [viewAction],
|
||||
duration: 10000
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isTerminalFailure(job.status)) {
|
||||
const detail = formatJobErrorText(job.error);
|
||||
notifyError(i18n.t("toast.processing.jobFailed"), {
|
||||
alert: "ai_fail",
|
||||
description: detail || typeLabel,
|
||||
actions: [viewAction],
|
||||
duration: 12000
|
||||
});
|
||||
}
|
||||
}
|
||||
if (toastedTerminalIds.size > 200) {
|
||||
toastedTerminalIds.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function syncDisplay(nextJobs: ProcessingJob[]) {
|
||||
notifyTerminalTransitions(nextJobs);
|
||||
const active = nextJobs.filter((j) => isActiveProcessingJob(j.status));
|
||||
if (active.length > 0) {
|
||||
displayJobs = active.slice(0, 3);
|
||||
if (lingerTimer !== undefined) {
|
||||
window.clearTimeout(lingerTimer);
|
||||
lingerTimer = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (displayJobs.length === 0 || lingerTimer !== undefined) return;
|
||||
lingerTimer = window.setTimeout(() => {
|
||||
displayJobs = [];
|
||||
lingerTimer = undefined;
|
||||
}, LINGER_MS);
|
||||
}
|
||||
|
||||
function jobProgress(job: ProcessingJob): { pct: number; done: number; total: number } {
|
||||
const total = typeof job.total_products === "number" ? job.total_products : 0;
|
||||
const done = typeof job.processed_products === "number" ? job.processed_products : 0;
|
||||
if (!total) return { pct: 0, done, total: 0 };
|
||||
return { pct: Math.min(100, Math.round((done / total) * 100)), done, total };
|
||||
}
|
||||
|
||||
function jobTitle(job: ProcessingJob): string {
|
||||
return formatProcessingTypeLabel(job.processing_type ?? job.type);
|
||||
}
|
||||
|
||||
async function refresh(signal?: AbortSignal) {
|
||||
const myGen = ++gen;
|
||||
try {
|
||||
const payload = await api<ListResponse<ProcessingJob>>("/api/processing/jobs?limit=50", {
|
||||
signal
|
||||
});
|
||||
if (myGen !== gen) return true;
|
||||
jobs = unwrapList(payload);
|
||||
syncDisplay(jobs);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (myGen !== gen) return true;
|
||||
// Aborted fetches are normal on unmount / overlapping ticks — do not enter the
|
||||
// slow retry backoff or the indicator stays blank across short-lived jobs.
|
||||
const aborted =
|
||||
signal?.aborted ||
|
||||
(err instanceof DOMException && err.name === "AbortError") ||
|
||||
(err instanceof Error && err.name === "AbortError");
|
||||
return aborted;
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(ms: number) {
|
||||
if (timer !== undefined) window.clearInterval(timer);
|
||||
timer = window.setInterval(() => {
|
||||
void tick();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
abort?.abort();
|
||||
const ac = new AbortController();
|
||||
abort = ac;
|
||||
const ok = await refresh(ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
schedule(ok ? POLL_MS : RETRY_MS);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
const ac = new AbortController();
|
||||
void (async () => {
|
||||
const ok = await refresh(ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
schedule(ok ? POLL_MS : RETRY_MS);
|
||||
})();
|
||||
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") void tick();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
const boot = window.setTimeout(() => {
|
||||
if (!ac.signal.aborted) void tick();
|
||||
}, 750);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(boot);
|
||||
if (lingerTimer !== undefined) window.clearTimeout(lingerTimer);
|
||||
ac.abort();
|
||||
abort?.abort();
|
||||
if (timer !== undefined) window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class="pointer-events-none fixed bottom-4 right-4 z-40 flex w-[min(100vw-2rem,22rem)] flex-col items-stretch gap-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="task-status-indicator"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-background/90"
|
||||
>
|
||||
<div class="flex items-start gap-2 border-b border-border px-3 py-2.5">
|
||||
<Layers class="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground">{i18n.t("taskIndicator.title")}</p>
|
||||
<p class="text-xs text-muted-foreground">{headline}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? i18n.t("taskIndicator.expand") : i18n.t("taskIndicator.collapse")}
|
||||
onclick={() => (collapsed = !collapsed)}
|
||||
>
|
||||
{#if collapsed}
|
||||
<ChevronUp class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !collapsed}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each previewJobs as job (job.id)}
|
||||
{@const { pct, done, total } = jobProgress(job)}
|
||||
<li class="space-y-1.5 px-3 py-2.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="truncate text-xs font-medium">{jobTitle(job)}</p>
|
||||
<Badge variant="outline" class="shrink-0 capitalize">
|
||||
{formatJobStatusLabel(job.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
{#if total > 0}
|
||||
<Progress value={pct} class="" />
|
||||
<p class="text-[11px] tabular-nums text-muted-foreground">
|
||||
{i18n.t("taskIndicator.progressCounts", { done, total })}
|
||||
</p>
|
||||
<span class="sr-only">{i18n.t("taskIndicator.progressAria", { pct })}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="border-t border-border px-3 py-2">
|
||||
<a href="/processing" class={buttonClasses("outline", "sm", "w-full justify-center")}>
|
||||
{i18n.t("taskIndicator.view")}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-3 py-2">
|
||||
<a
|
||||
href="/processing"
|
||||
class="text-xs font-medium text-link underline-offset-4 hover:underline"
|
||||
>
|
||||
{i18n.t("taskIndicator.view")}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user