From 1f17419ee918965607faee1eafa45c83b871606b Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 3 May 2026 12:47:31 +0000 Subject: [PATCH] ui(precedents): live status pill with shimmer + auto-queue + auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chair pointed out three UX gaps after uploading a new precedent: 1. The status said "מחלץ הלכות" but nothing was actually running — the field only meant "halacha_extraction_status != completed", which includes the post-upload "pending" state where the local MCP worker hasn't been told to drain anything yet. Misleading. 2. The page didn't refresh on its own. The chair had to F5 to see new counts after extraction completed. 3. Clicking the trash icon mid-extraction would cascade-delete the row while the extractor was still using it (FK errors, partial writes). Fixes: - ingest_precedent now auto-queues both metadata and halacha extraction on upload by stamping the request timestamps. The chair (or me) drains the queue with one `precedent_process_pending` call from chat — no need to click any button before that. - StatusPill is now five-state with proper labels: "נכשל" (extraction_status=failed) — red "מעבד טקסט" — shimmer (extraction_status=processing) "בתור" — neutral (chunks queued, not yet running) "מחלץ הלכות" — shimmer (halacha_extraction_status=processing) "ממתין לחילוץ" — neutral (queued for local MCP worker) "לא חולץ" — neutral (pending without queue stamp — shouldn't happen) "X/Y מאושרות" — gold (done, with halachot count) The shimmer is a CSS-only sliding-stripe animation defined in globals. - usePrecedents has a conditional refetchInterval — polls every 5s while any row is mid-extraction or queued, then stops once everything settles to completed/failed. New helper isPrecedentActive() centralises the "is this row mid-something" check so the UI and the destructive-action guard agree. - Trash button is disabled (opacity 30%, tooltip explains) while the row is active. Pencil/edit stays enabled — editing metadata fields during extraction is safe (last write wins, low-stakes race). Schema: list_external_case_law now exposes the two *_requested_at timestamps so the UI can distinguish "queued" from "never asked". Co-Authored-By: Claude Opus 4.7 (1M context) --- mcp-server/src/legal_mcp/services/db.py | 12 ++- .../legal_mcp/services/precedent_library.py | 16 ++-- web-ui/src/app/globals.css | 21 ++++++ .../precedents/library-list-panel.tsx | 73 +++++++++++++++++-- web-ui/src/lib/api/precedent-library.ts | 22 ++++++ 5 files changed, 131 insertions(+), 13 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 26e66e8..7428bfb 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1833,6 +1833,8 @@ async def list_external_case_law( appeal_subtype, source_type, precedent_level, is_binding, summary, headnote, subject_tags, source_kind, extraction_status, halacha_extraction_status, + metadata_extraction_requested_at, + halacha_extraction_requested_at, created_at, (SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id) AS halachot_count, (SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id @@ -1843,7 +1845,15 @@ async def list_external_case_law( LIMIT ${idx} OFFSET ${idx + 1} """ rows = await pool.fetch(sql, *params) - return [_row_to_case_law(r) for r in rows] + out = [] + for r in rows: + d = _row_to_case_law(r) + # Render timestamps as ISO strings so the JSON layer stays simple + for k in ("metadata_extraction_requested_at", "halacha_extraction_requested_at"): + if d.get(k) is not None: + d[k] = d[k].isoformat() + out.append(d) + return out async def delete_case_law(case_law_id: UUID) -> bool: diff --git a/mcp-server/src/legal_mcp/services/precedent_library.py b/mcp-server/src/legal_mcp/services/precedent_library.py index 1a1776b..a401884 100644 --- a/mcp-server/src/legal_mcp/services/precedent_library.py +++ b/mcp-server/src/legal_mcp/services/precedent_library.py @@ -190,19 +190,23 @@ async def ingest_precedent( # Pipeline split: the container does the non-LLM half (extract + # chunk + embed + store). LLM-driven extraction (metadata, halachot) - # runs separately via the MCP tools `precedent_extract_metadata` / - # `precedent_extract_halachot` from local Claude Code, where - # `claude` CLI is available. Mark statuses so the chair can see - # what's pending in the UI. + # runs separately via the MCP tool `precedent_process_pending` from + # local Claude Code, where `claude` CLI is available. + # + # We auto-queue both extractions so the chair doesn't need to click + # any button — the moment they (or me) run `precedent_process_pending` + # in chat, both kinds get processed. await db.set_case_law_extraction_status(case_law_id, "completed") await db.set_case_law_halacha_status(case_law_id, "pending") + await db.request_metadata_extraction(case_law_id) + await db.request_halacha_extraction(case_law_id) await progress( "completed", 100, f"הוכנס לספרייה: {stored_chunks} chunks. " - f"חילוץ הלכות ומטא-דאטה — להפעיל מ-Claude Code " - f"(precedent_extract_halachot / precedent_extract_metadata).", + f"חילוץ הלכות ומטא-דאטה ממתינים בתור — " + f"להפעיל מ-Claude Code: precedent_process_pending.", ) return { diff --git a/web-ui/src/app/globals.css b/web-ui/src/app/globals.css index e30afb9..fd6ef72 100644 --- a/web-ui/src/app/globals.css +++ b/web-ui/src/app/globals.css @@ -246,3 +246,24 @@ color: var(--color-navy); } } + +/* ── Status pill shimmer ────────────────────────────────────────── + * Indeterminate "in progress" indicator used by precedent-library + * StatusPill while extraction is running. A diagonal stripe slides + * left-to-right across the badge background. */ +@keyframes ezer-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.shimmer-active { + background-image: linear-gradient( + 90deg, + transparent 0%, + rgba(168, 124, 58, 0.18) 50%, + transparent 100% + ); + background-size: 200% 100%; + background-repeat: no-repeat; + animation: ezer-shimmer 1.6s linear infinite; +} diff --git a/web-ui/src/components/precedents/library-list-panel.tsx b/web-ui/src/components/precedents/library-list-panel.tsx index bf19cd7..4ae9866 100644 --- a/web-ui/src/components/precedents/library-list-panel.tsx +++ b/web-ui/src/components/precedents/library-list-panel.tsx @@ -16,6 +16,7 @@ import { import { usePrecedents, useDeletePrecedent, + isPrecedentActive, type Precedent, type PracticeArea, } from "@/lib/api/precedent-library"; @@ -41,16 +42,68 @@ function cleanCitation(s: string | null | undefined): string { return s.replace(/[‎‏‪-‮⁦-⁩]/g, "").trim(); } +/* Shimmering pill — used while extraction is actively running. + * Visually distinct from the static "queued" / "completed" pills. */ +function ActivePill({ label }: { label: string }) { + return ( + + {label} + + ); +} + +/** + * Five distinct states. The "queued" state is what the user actually + * sees most of the time (after upload, both extractions are auto-queued + * but the local MCP worker hasn't drained them yet); "מחלץ" / "מעבד" + * shimmers and only appears while the extractor is actively running. + */ function StatusPill({ p }: { p: Precedent }) { if (p.extraction_status === "failed") { - return נכשל; + return ( + + נכשל + + ); + } + if (p.extraction_status === "processing") { + return ; } if (p.extraction_status !== "completed") { - return בעיבוד; + return ( + + בתור + + ); } - if (p.halacha_extraction_status !== "completed") { - return מחלץ הלכות; + if (p.halacha_extraction_status === "processing") { + return ; } + if (p.halacha_extraction_status === "failed") { + return ( + + חילוץ נכשל + + ); + } + if (p.halacha_extraction_status === "pending") { + if (p.halacha_extraction_requested_at) { + return ( + + ממתין לחילוץ + + ); + } + return ( + + לא חולץ + + ); + } + // halacha_extraction_status === "completed" if (p.halachot_count === 0) { return ללא הלכות; } @@ -71,8 +124,15 @@ function PrecedentRow({ onEdit: (id: string) => void; }) { const del = useDeletePrecedent(); + const active = isPrecedentActive(p); const onDelete = async () => { + if (active) { + toast.error( + "מתבצע עיבוד — לא ניתן למחוק עכשיו. המתיני לסיום או רעני את הדף.", + ); + return; + } if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return; try { await del.mutateAsync(p.id); @@ -136,9 +196,10 @@ function PrecedentRow({ diff --git a/web-ui/src/lib/api/precedent-library.ts b/web-ui/src/lib/api/precedent-library.ts index 437820f..9a46fc5 100644 --- a/web-ui/src/lib/api/precedent-library.ts +++ b/web-ui/src/lib/api/precedent-library.ts @@ -48,6 +48,8 @@ export type Precedent = { source_kind: string; extraction_status: string; halacha_extraction_status: string; + metadata_extraction_requested_at: string | null; + halacha_extraction_requested_at: string | null; created_at: string; halachot_count: number; approved_count: number; @@ -172,9 +174,29 @@ export function usePrecedents(filters: ListFilters = {}) { ); }, staleTime: 30_000, + /* Poll while any row is mid-processing or queued for the local MCP + * worker. Once everything settles to completed/failed the polling + * stops on its own — no fixed background timer. */ + refetchInterval: (query) => { + const data = query.state.data; + if (!data) return false; + const active = data.items.some((p) => isPrecedentActive(p)); + return active ? 5000 : false; + }, }); } +/** A precedent is "active" while text/halacha extraction is in flight or + * queued for the local MCP worker. Used by the auto-refresh poller and + * by the row UI to disable destructive actions. */ +export function isPrecedentActive(p: Precedent): boolean { + if (p.extraction_status === "processing") return true; + if (p.halacha_extraction_status === "processing") return true; + if (p.halacha_extraction_requested_at !== null) return true; + if (p.metadata_extraction_requested_at !== null) return true; + return false; +} + export function usePrecedent(id: string | null) { return useQuery({ queryKey: libraryKeys.detail(id ?? ""),