diff --git a/web-ui/src/components/app-shell.tsx b/web-ui/src/components/app-shell.tsx index d2b2d4a..8852f08 100644 --- a/web-ui/src/components/app-shell.tsx +++ b/web-ui/src/components/app-shell.tsx @@ -13,6 +13,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { GlobalSearch } from "@/components/global-search"; type AgentBoard = { prefix: string; @@ -83,7 +84,7 @@ export function AppShell({ children }: { children: ReactNode }) { +
+ +
+ (null); + const wrapperRef = useRef(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 ( +
+
+
+ + {showPanel && ( +
+ {anyLoading && !hasAnyResults && ( +
+
+ )} + + + + + + + + {!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."""