(null);
+ const listboxId = useId();
+
+ // Debounce.
+ useEffect(() => {
+ const t = setTimeout(() => setDebounced(raw), DEBOUNCE_MS);
+ return () => clearTimeout(t);
+ }, [raw]);
+
+ // Cmd/Ctrl+K — focus + select.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ const isModK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
+ if (isModK) {
+ e.preventDefault();
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, []);
+
+ // Click-outside.
+ useEffect(() => {
+ if (!open) return;
+ const onClick = (e: MouseEvent) => {
+ if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
+ };
+ window.addEventListener("mousedown", onClick);
+ return () => window.removeEventListener("mousedown", onClick);
+ }, [open]);
+
+ // Route changes don't need a separate listener: clicking any link
+ // (nav, search result, in-page) triggers mousedown outside wrapperRef,
+ // and the click-outside effect closes the panel.
+
+ const { cases, precedent, documents, isQueryReady, anyLoading } =
+ useGlobalSearch(debounced);
+
+ const showPanel = open && isQueryReady;
+
+ const hasAnyResults = useMemo(() => {
+ return (
+ (cases.data?.items.length ?? 0) > 0 ||
+ (precedent.data?.items.length ?? 0) > 0 ||
+ (documents.data?.length ?? 0) > 0
+ );
+ }, [cases.data, precedent.data, documents.data]);
+
+ return (
+
+
+
+ {
+ setRaw(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => setOpen(true)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ setOpen(false);
+ inputRef.current?.blur();
+ }
+ }}
+ placeholder="חפש תיק, פסיקה, או מסמך…"
+ aria-label="חיפוש גלובלי"
+ autoComplete="off"
+ spellCheck={false}
+ className="
+ w-full ps-3 pe-10 py-2 rounded-md
+ bg-navy-soft/60 border border-parchment/15
+ text-sm text-parchment placeholder:text-parchment/40
+ focus:outline-none focus:ring-2 focus:ring-gold/60 focus:border-transparent
+ transition-colors
+ "
+ />
+
+ ⌘K
+
+
+
+ {showPanel && (
+
+ {anyLoading && !hasAnyResults && (
+
+
+ מחפש…
+
+ )}
+
+
}
+ isLoading={cases.isLoading}
+ isError={cases.isError}
+ count={cases.data?.count}
+ >
+ {cases.data?.items.map((hit) => (
+
setOpen(false)} />
+ ))}
+
+
+ }
+ isLoading={precedent.isLoading}
+ isError={precedent.isError}
+ count={precedent.data?.count}
+ seeMoreHref={`/precedents?q=${encodeURIComponent(debounced)}`}
+ >
+ {precedent.data?.items.map((hit, i) => (
+ setOpen(false)} />
+ ))}
+
+
+ }
+ isLoading={documents.isLoading}
+ isError={documents.isError}
+ count={documents.data?.length}
+ >
+ {documents.data?.map((hit, i) => (
+ setOpen(false)} />
+ ))}
+
+
+ {!anyLoading && !hasAnyResults && (
+
+ לא נמצא כלום עבור {debounced}.
+
+ נסה מילים נרדפות או חפש לפי מספר תיק.
+
+ )}
+
+ )}
+
+ );
+}
+
+// ─── Result groups + rows ────────────────────────────────────────────
+
+function ResultGroup({
+ title,
+ icon,
+ isLoading,
+ isError,
+ count,
+ seeMoreHref,
+ children,
+}: {
+ title: string;
+ icon: React.ReactNode;
+ isLoading: boolean;
+ isError: boolean;
+ count: number | undefined;
+ seeMoreHref?: string;
+ children: React.ReactNode;
+}) {
+ const hasItems = (count ?? 0) > 0;
+
+ return (
+
+
+ {icon}
+ {title}
+ {count !== undefined && (
+ ({count})
+ )}
+ {isLoading && }
+
+
+ {isError && (
+
שגיאה בטעינת תוצאות
+ )}
+
+ {!isLoading && !isError && !hasItems && (
+
אין תוצאות בקטגוריה זו
+ )}
+
+
{children}
+
+ {seeMoreHref && hasItems && (
+
+ הצג עוד →
+
+ )}
+
+ );
+}
+
+function CaseRow({ hit, onSelect }: { hit: CaseHit; onSelect: () => void }) {
+ return (
+
+
+ ערר {hit.case_number}
+ {hit.status && (
+
+ {hit.status}
+
+ )}
+
+
+ {hit.title}
+ {hit.property_address && · {hit.property_address}}
+
+
+ );
+}
+
+function PrecedentRow({ hit, onSelect }: { hit: PrecedentHit; onSelect: () => void }) {
+ const href = `/precedents/${hit.case_law_id}`;
+ const pct = Math.round(hit.score * 100);
+
+ if (hit.type === "halacha") {
+ return (
+
+
+ הלכה — {hit.rule_statement}
+ {pct}%
+
+
+ {hit.case_name} · {hit.case_number}
+
+
+ );
+ }
+
+ return (
+
+
+ פסקה
+ {pct}%
+
+
+ “{hit.content.slice(0, 160)}…”
+
+
+ {hit.case_name} · {hit.case_number}
+
+
+ );
+}
+
+function DocumentRow({ hit, onSelect }: { hit: DocumentHit; onSelect: () => void }) {
+ const pct = Math.round(hit.score * 100);
+ return (
+
+
+ {hit.document}
+ {pct}%
+
+
+ “{hit.content.slice(0, 160)}…”
+
+
+ ערר {hit.case_number}
+ {hit.page != null && · עמ׳ {hit.page}}
+
+
+ );
+}
diff --git a/web-ui/src/lib/api/global-search.ts b/web-ui/src/lib/api/global-search.ts
new file mode 100644
index 0000000..709c08e
--- /dev/null
+++ b/web-ui/src/lib/api/global-search.ts
@@ -0,0 +1,113 @@
+/**
+ * Global header search — fans out to three independent sources in parallel.
+ *
+ * Each source is its own `useQuery`, so the fastest one (cases, plain SQL)
+ * shows up immediately while the slower vector searches stream in. A failure
+ * in one source does not block the others — the result panel renders per-
+ * source skeletons and per-source error states.
+ *
+ * Sources:
+ * - cases → GET /api/search/cases (SQL ILIKE on case_number/address/parties)
+ * - precedent → GET /api/precedent-library/search (semantic, halachot+chunks)
+ * - documents → GET /api/search (semantic, all case docs + past decisions)
+ */
+
+import { useQuery } from "@tanstack/react-query";
+import { apiRequest } from "./client";
+import type { SearchHit as PrecedentHit } from "./precedent-library";
+
+export type CaseHit = {
+ case_number: string;
+ title: string;
+ property_address: string | null;
+ status: string | null;
+ practice_area: string | null;
+ appeal_subtype: string | null;
+};
+
+export type DocumentHit = {
+ score: number;
+ case_number: string;
+ document: string;
+ section: string;
+ page: number | null;
+ content: string;
+};
+
+const MIN_QUERY_LEN = 2;
+const STALE_MS = 10_000;
+const PER_SOURCE_LIMIT = 5;
+
+const enabled = (q: string) => q.trim().length >= MIN_QUERY_LEN;
+
+export function useCasesSearch(query: string) {
+ return useQuery({
+ queryKey: ["global-search", "cases", query],
+ queryFn: ({ signal }) =>
+ apiRequest<{ items: CaseHit[]; count: number }>(
+ `/api/search/cases?q=${encodeURIComponent(query)}&limit=${PER_SOURCE_LIMIT}`,
+ { signal },
+ ),
+ enabled: enabled(query),
+ staleTime: STALE_MS,
+ placeholderData: (prev) => prev,
+ });
+}
+
+export function usePrecedentSearch(query: string) {
+ return useQuery({
+ queryKey: ["global-search", "precedent", query],
+ queryFn: ({ signal }) => {
+ const p = new URLSearchParams({
+ q: query,
+ limit: String(PER_SOURCE_LIMIT),
+ include_halachot: "true",
+ });
+ return apiRequest<{ items: PrecedentHit[]; count: number }>(
+ `/api/precedent-library/search?${p.toString()}`,
+ { signal },
+ );
+ },
+ enabled: enabled(query),
+ staleTime: STALE_MS,
+ placeholderData: (prev) => prev,
+ });
+}
+
+/**
+ * The /api/search endpoint returns either an array of DocumentHit, or
+ * `{message: "לא נמצאו תוצאות."}` when empty. Normalize to a plain array.
+ */
+export function useDocumentsSearch(query: string) {
+ return useQuery({
+ queryKey: ["global-search", "documents", query],
+ queryFn: async ({ signal }) => {
+ const raw = await apiRequest(
+ `/api/search?query=${encodeURIComponent(query)}&limit=${PER_SOURCE_LIMIT}`,
+ { signal },
+ );
+ return Array.isArray(raw) ? raw : [];
+ },
+ enabled: enabled(query),
+ staleTime: STALE_MS,
+ placeholderData: (prev) => prev,
+ });
+}
+
+export function useGlobalSearch(query: string) {
+ const cases = useCasesSearch(query);
+ const precedent = usePrecedentSearch(query);
+ const documents = useDocumentsSearch(query);
+
+ const isQueryReady = enabled(query);
+ const anyLoading =
+ isQueryReady && (cases.isLoading || precedent.isLoading || documents.isLoading);
+
+ return {
+ cases,
+ precedent,
+ documents,
+ isQueryReady,
+ anyLoading,
+ };
+}
diff --git a/web/app.py b/web/app.py
index 23e9fdd..aaeb9fe 100644
--- a/web/app.py
+++ b/web/app.py
@@ -1387,6 +1387,55 @@ async def api_case_search(case_number: str, query: str, limit: int = 10):
return {"message": result}
+@app.get("/api/search/cases")
+async def api_search_cases(q: str, limit: int = 10):
+ """Lightweight SQL search over cases — by case number, address, parties, title.
+
+ Powers the global-search dropdown in the header. Returns small projections,
+ not full case objects.
+ """
+ q = q.strip()
+ if len(q) < 2:
+ return {"items": [], "count": 0}
+
+ needle = f"%{q}%"
+ prefix = f"{q}%"
+
+ pool = await db.get_pool()
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT case_number, title, property_address, status,
+ practice_area, appeal_subtype, updated_at
+ FROM cases
+ WHERE case_number ILIKE $1
+ OR property_address ILIKE $1
+ OR title ILIKE $1
+ OR subject ILIKE $1
+ OR appellants::text ILIKE $1
+ OR respondents::text ILIKE $1
+ ORDER BY
+ CASE WHEN case_number ILIKE $2 THEN 0 ELSE 1 END,
+ updated_at DESC NULLS LAST
+ LIMIT $3
+ """,
+ needle, prefix, limit,
+ )
+
+ items = [
+ {
+ "case_number": r["case_number"],
+ "title": r["title"],
+ "property_address": r["property_address"],
+ "status": r["status"],
+ "practice_area": r["practice_area"],
+ "appeal_subtype": r["appeal_subtype"],
+ }
+ for r in rows
+ ]
+ return {"items": items, "count": len(items)}
+
+
@app.get("/api/cases/{case_number}/template")
async def api_case_template(case_number: str):
"""Get outcome-aware decision template for a case."""