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 ?? ""),