עד כה ה-"מייצר…" כיסה רק את שנייה-ה-POST; אחרי זה לא היה חיווי אם ההפקה
רצה/הצליחה/נכשלה — ריצה שבוטלה נראתה כמו הצלחה. עכשיו צ'יפ-סטטוס ליד כל
כפתור-הפקה: בתור · רץ+שעון-חי · הושלם+מתי · נכשל+סיבה+"נסה שוב".
**נשמר-שרת (דרישת חיים):** הסטטוס נגזר בשרת מ-issue-הילד של ה-CEO +
ה-heartbeat_run האחרון + קיום-הקובץ — לא מזיכרון-הדף. שעון-הריצה מעוגן
ל-started_at של השרת, כך שעזיבה+חזרה לדף מציגות את המצב והזמן הנכונים,
בלי צורך להישאר בדף.
Backend:
- paperclip_client.get_generation_run_status — קורא issue-ילד + latest run
מ-Paperclip DB (read-only, מאחורי שער-הפלטפורמה G12); dedup לפי כותרת.
- GET /api/cases/{n}/generate/{action}/status — ממפה ל-idle/queued/running/
done/failed + output_ready + started_at/finished_at (UTC). run שהסתיים בלי
קובץ = failed (שלא ייראה כהצלחה).
Frontend:
- useGenerateStatus (poll 5s רק כשפעיל; עוצר בטרמינלי) + GenerateStatusChip
(שעון-ריצה חי מ-started_at; רכיב חסר-מצב). כפתורי-ההפקה מרעננים את
שאילתת-הסטטוס ב-onSuccess. שורה 3 (החלטה מלאה, סינכרוני) — בלי צ'יפ.
עיצוב: מוקאפ 29-generate-status-indicator אושר ע"י חיים דרך שער Claude Design (X17).
tsc + eslint + py_compile ירוקים. (api:types לריענון post-deploy.)
Invariants: G12 (Paperclip רק בשער), G1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
128 lines
3.8 KiB
TypeScript
128 lines
3.8 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { CheckCircle2, Clock, Loader2, RotateCw, XCircle } from "lucide-react";
|
||
import type { GenerateStatus } from "@/lib/api/generate";
|
||
|
||
/**
|
||
* Background-run status chip for the "הפקת מסמכים" generation actions (#227 ג).
|
||
*
|
||
* The chip is driven entirely by the server-derived {@link GenerateStatus} — it
|
||
* holds no run state of its own, so it renders correctly after the chair
|
||
* navigates away and back. The running timer is anchored to the server
|
||
* `started_at`, not a click-time counter, so the elapsed value is right on
|
||
* return too.
|
||
*/
|
||
|
||
function useElapsedSeconds(startedAt: string | null, active: boolean): number | null {
|
||
const [now, setNow] = useState(() => Date.now());
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
const id = setInterval(() => setNow(Date.now()), 1_000);
|
||
return () => clearInterval(id);
|
||
}, [active]);
|
||
if (!startedAt) return null;
|
||
const start = Date.parse(startedAt);
|
||
if (Number.isNaN(start)) return null;
|
||
return Math.max(0, Math.floor((now - start) / 1000));
|
||
}
|
||
|
||
function fmtDuration(sec: number): string {
|
||
const m = Math.floor(sec / 60);
|
||
const s = sec % 60;
|
||
if (m >= 60) {
|
||
const h = Math.floor(m / 60);
|
||
return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||
}
|
||
return `${m}:${String(s).padStart(2, "0")}`;
|
||
}
|
||
|
||
function relativeTime(iso: string | null): string {
|
||
if (!iso) return "";
|
||
const t = Date.parse(iso);
|
||
if (Number.isNaN(t)) return "";
|
||
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
||
if (sec < 60) return "הרגע";
|
||
const min = Math.floor(sec / 60);
|
||
if (min < 60) return `לפני ${min} דק׳`;
|
||
const hr = Math.floor(min / 60);
|
||
if (hr < 24) return `לפני ${hr} שע׳`;
|
||
return `לפני ${Math.floor(hr / 24)} ימים`;
|
||
}
|
||
|
||
const CHIP_BASE =
|
||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold whitespace-nowrap";
|
||
|
||
export function GenerateStatusChip({
|
||
status,
|
||
onRetry,
|
||
retrying,
|
||
}: {
|
||
status: GenerateStatus | undefined;
|
||
onRetry?: () => void;
|
||
retrying?: boolean;
|
||
}) {
|
||
const state = status?.state ?? "idle";
|
||
const elapsed = useElapsedSeconds(status?.started_at ?? null, state === "running");
|
||
|
||
if (!status || state === "idle") return null;
|
||
|
||
if (state === "queued") {
|
||
return (
|
||
<span className={`${CHIP_BASE} bg-rule-soft text-ink-soft border-rule`}>
|
||
<Clock className="size-3.5" aria-hidden />
|
||
בתור…
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (state === "running") {
|
||
return (
|
||
<span className={`${CHIP_BASE} bg-info-bg text-info border-info/35`}>
|
||
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||
רץ ברקע
|
||
{elapsed !== null && (
|
||
<span className="tabular-nums">· {fmtDuration(elapsed)}</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (state === "done") {
|
||
const rel = relativeTime(status.finished_at);
|
||
return (
|
||
<span className={`${CHIP_BASE} bg-success-bg text-success border-success/35`}>
|
||
<CheckCircle2 className="size-3.5" aria-hidden />
|
||
הושלם{rel && ` · ${rel}`}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// failed
|
||
return (
|
||
<span className="inline-flex items-center gap-2">
|
||
<span className={`${CHIP_BASE} bg-danger-bg text-danger border-danger/35`}>
|
||
<XCircle className="size-3.5" aria-hidden />
|
||
{status.detail || "נכשל"}
|
||
</span>
|
||
{onRetry && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 px-2 text-gold-deep"
|
||
disabled={retrying}
|
||
onClick={onRetry}
|
||
>
|
||
{retrying ? (
|
||
<Loader2 className="size-3.5 animate-spin me-1" />
|
||
) : (
|
||
<RotateCw className="size-3.5 me-1" />
|
||
)}
|
||
נסה שוב
|
||
</Button>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|