feat(generate): סימון-סטטוס לריצת-הרקע של "הפקת מסמכים" (#227 ג)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 15s

עד כה ה-"מייצר…" כיסה רק את שנייה-ה-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>
This commit is contained in:
2026-07-06 11:33:41 +00:00
parent 21ad541659
commit b296422d87
6 changed files with 360 additions and 3 deletions

View File

@@ -31,7 +31,9 @@ import {
usePartyClaimsSummary,
useGeneratePartyClaimsSummary,
useGenerateInterimDraft,
useGenerateStatus,
} from "@/lib/api/generate";
import { GenerateStatusChip } from "@/components/cases/generate-status-chip";
import {
useCaseFeedback,
useCreateFeedback,
@@ -133,6 +135,9 @@ export function DraftsPanel({
const { data: summary } = usePartyClaimsSummary(caseNumber);
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
const genInterim = useGenerateInterimDraft(caseNumber);
// Server-derived background-run status (#227 ג) — survives navigation/reload.
const summaryStatus = useGenerateStatus(caseNumber, "party_claims_summary");
const interimStatus = useGenerateStatus(caseNumber, "interim_draft");
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
@@ -342,6 +347,11 @@ export function DraftsPanel({
"הפק"
)}
</Button>
<GenerateStatusChip
status={summaryStatus.data}
onRetry={handleGenerateSummary}
retrying={genSummary.isPending}
/>
{summaryReady && (
<>
<Button
@@ -410,6 +420,11 @@ export function DraftsPanel({
"הפק"
)}
</Button>
<GenerateStatusChip
status={interimStatus.data}
onRetry={handleGenerateInterim}
retrying={genInterim.isPending}
/>
{latestInterim && (
<span className="text-[0.7rem] text-ink-muted">
{latestInterim.version

View File

@@ -0,0 +1,127 @@
"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>
);
}

View File

@@ -29,12 +29,58 @@ export type GenerateTriggerResult = {
error?: string;
};
/** The two host-side background generation actions (poll target of the status chip). */
export type GenerateAction = "party_claims_summary" | "interim_draft";
export const generateKeys = {
all: ["generate"] as const,
partyClaimsSummary: (caseNumber: string) =>
[...generateKeys.all, "party-claims-summary", caseNumber] as const,
status: (caseNumber: string, action: GenerateAction) =>
[...generateKeys.all, "status", action, caseNumber] as const,
};
/**
* Server-derived background-run status (#227 ג). Reconstructed on the backend
* from the CEO child issue + its heartbeat_run + the output artifact — so it is
* correct after navigating away and back; it never depends on the browser having
* stayed on the page. `started_at`/`finished_at` are server ISO-8601 UTC stamps,
* so the client renders a correct elapsed time on return.
*/
export type GenerateState = "idle" | "queued" | "running" | "done" | "failed";
export type GenerateStatus = {
case_number: string;
action: GenerateAction;
state: GenerateState;
started_at: string | null;
finished_at: string | null;
output_ready: boolean;
detail: string;
issue_id: string | null;
};
export function useGenerateStatus(
caseNumber: string | undefined,
action: GenerateAction,
) {
return useQuery<GenerateStatus>({
queryKey: generateKeys.status(caseNumber ?? "", action),
queryFn: ({ signal }) =>
apiRequest<GenerateStatus>(
`/api/cases/${caseNumber}/generate/${action}/status`,
{ signal },
),
enabled: Boolean(caseNumber),
// Poll only while a run is in flight; a terminal state stops the interval.
refetchInterval: (query) => {
const s = query.state.data?.state;
return s === "running" || s === "queued" ? 5_000 : false;
},
staleTime: 2_000,
});
}
/* ── Read: party-claims executive summary (poll target for button 1) ──
404 (not generated yet) is a normal "not ready" state, not an error — we
coerce it to null so the panel renders the empty/idle state cleanly. */
@@ -70,23 +116,33 @@ export function useGeneratePartyClaimsSummary(caseNumber: string) {
{ method: "POST" },
),
onSuccess: () => {
// Start polling fresh — the file will appear after the host run completes.
// Start polling fresh — the file will appear after the host run completes,
// and the status chip should pick up the new queued/running run.
qc.invalidateQueries({
queryKey: generateKeys.partyClaimsSummary(caseNumber),
});
qc.invalidateQueries({
queryKey: generateKeys.status(caseNumber, "party_claims_summary"),
});
},
});
}
/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */
export function useGenerateInterimDraft(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<GenerateTriggerResult>(
`/api/cases/${caseNumber}/generate/interim-draft`,
{ method: "POST" },
),
// The result lands in the exports list (טיוטת-ביניים-*.docx); useExports
// already polls on a 5s interval, so no extra invalidation is needed here.
// The result lands in the exports list (טיוטת-ביניים-*.docx, polled by
// useExports); re-arm the status chip so it shows the new run immediately.
onSuccess: () => {
qc.invalidateQueries({
queryKey: generateKeys.status(caseNumber, "interim_draft"),
});
},
});
}