feat(search): add header global search (Phase A) — cases + precedents + docs
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s

Adds an always-visible debounced search input in the AppShell header
that fans out to three independent sources in parallel and renders
per-source result groups with their own loading/empty/error states:

- /api/search/cases (NEW): SQL ILIKE on case_number, address, parties,
  title, subject. Returns small projections, no embeddings needed.
- /api/precedent-library/search (existing): semantic over case-law
  halachot + passages.
- /api/search (existing): semantic over case documents + past decisions.

Cmd/Ctrl+K focuses the input; Esc and click-outside close the panel.
This is Phase A of the header redesign — the bar layout itself is
unchanged; row grouping + dynamic context follow in Phase B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:05:51 +00:00
parent cbdbc522a0
commit f722fa45bd
4 changed files with 508 additions and 1 deletions

View File

@@ -13,6 +13,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { GlobalSearch } from "@/components/global-search";
type AgentBoard = { type AgentBoard = {
prefix: string; prefix: string;
@@ -83,7 +84,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</Link> </Link>
<nav <nav
className="me-auto flex items-center gap-1" className="flex items-center gap-1"
aria-label="ניווט ראשי" aria-label="ניווט ראשי"
> >
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
@@ -114,6 +115,10 @@ export function AppShell({ children }: { children: ReactNode }) {
})} })}
</nav> </nav>
<div className="flex-1 min-w-0 max-w-[460px] mx-4 flex justify-center">
<GlobalSearch />
</div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className=" className="

View File

@@ -0,0 +1,340 @@
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { Search, FileText, BookOpen, FolderOpen, Loader2 } from "lucide-react";
import {
useGlobalSearch,
type CaseHit,
type DocumentHit,
} from "@/lib/api/global-search";
import type { SearchHit as PrecedentHit } from "@/lib/api/precedent-library";
const DEBOUNCE_MS = 250;
/**
* Header global search — debounced input + per-source result panel.
*
* Three independent fan-out queries (cases / precedent / documents) each
* render their own section with an own loading/empty state, so the fast
* SQL source paints immediately while the slower vector sources stream in.
*/
export function GlobalSearch() {
const [raw, setRaw] = useState("");
const [debounced, setDebounced] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLDivElement>(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 (
<div ref={wrapperRef} className="relative w-full max-w-[460px]">
<div className="relative">
<Search
className="absolute end-3 top-1/2 -translate-y-1/2 size-4 text-parchment/50 pointer-events-none"
aria-hidden="true"
/>
<input
ref={inputRef}
type="search"
value={raw}
onChange={(e) => {
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
"
/>
<kbd
className="
absolute end-9 top-1/2 -translate-y-1/2
text-[10px] font-mono text-parchment/40
border border-parchment/20 rounded px-1 py-0.5
pointer-events-none select-none
hidden md:inline-block
"
aria-hidden="true"
>
K
</kbd>
</div>
{showPanel && (
<div
id={listboxId}
role="listbox"
aria-label="תוצאות חיפוש"
className="
absolute top-full inset-x-0 mt-2 z-50
rounded-lg bg-popover text-popover-foreground
shadow-xl ring-1 ring-foreground/10
max-h-[70vh] overflow-y-auto
"
dir="rtl"
>
{anyLoading && !hasAnyResults && (
<div className="px-4 py-6 text-sm text-muted-foreground flex items-center gap-2">
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
מחפש
</div>
)}
<ResultGroup
title="תיקים"
icon={<FolderOpen className="size-4" aria-hidden="true" />}
isLoading={cases.isLoading}
isError={cases.isError}
count={cases.data?.count}
>
{cases.data?.items.map((hit) => (
<CaseRow key={hit.case_number} hit={hit} onSelect={() => setOpen(false)} />
))}
</ResultGroup>
<ResultGroup
title="ספריית פסיקה"
icon={<BookOpen className="size-4" aria-hidden="true" />}
isLoading={precedent.isLoading}
isError={precedent.isError}
count={precedent.data?.count}
seeMoreHref={`/precedents?q=${encodeURIComponent(debounced)}`}
>
{precedent.data?.items.map((hit, i) => (
<PrecedentRow key={`${hit.case_law_id}-${i}`} hit={hit} onSelect={() => setOpen(false)} />
))}
</ResultGroup>
<ResultGroup
title="מסמכי תיקים והחלטות"
icon={<FileText className="size-4" aria-hidden="true" />}
isLoading={documents.isLoading}
isError={documents.isError}
count={documents.data?.length}
>
{documents.data?.map((hit, i) => (
<DocumentRow key={`${hit.case_number}-${i}`} hit={hit} onSelect={() => setOpen(false)} />
))}
</ResultGroup>
{!anyLoading && !hasAnyResults && (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
לא נמצא כלום עבור <span className="font-mono">{debounced}</span>.
<br />
נסה מילים נרדפות או חפש לפי מספר תיק.
</div>
)}
</div>
)}
</div>
);
}
// ─── 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 (
<div className="border-b border-foreground/5 last:border-b-0">
<div className="flex items-center gap-2 px-3 pt-3 pb-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{icon}
<span>{title}</span>
{count !== undefined && (
<span className="text-foreground/50 normal-case font-normal">({count})</span>
)}
{isLoading && <Loader2 className="size-3 animate-spin ms-auto" aria-hidden="true" />}
</div>
{isError && (
<div className="px-3 pb-2 text-xs text-destructive">שגיאה בטעינת תוצאות</div>
)}
{!isLoading && !isError && !hasItems && (
<div className="px-3 pb-2 text-xs text-muted-foreground/70">אין תוצאות בקטגוריה זו</div>
)}
<div className="flex flex-col">{children}</div>
{seeMoreHref && hasItems && (
<Link
href={seeMoreHref}
className="block px-3 py-1.5 text-xs text-gold-deep hover:bg-accent/40 transition-colors"
>
הצג עוד
</Link>
)}
</div>
);
}
function CaseRow({ hit, onSelect }: { hit: CaseHit; onSelect: () => void }) {
return (
<Link
href={`/cases/${encodeURIComponent(hit.case_number)}`}
onClick={onSelect}
role="option"
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
>
<div className="flex items-baseline justify-between gap-3">
<span className="font-medium text-sm">ערר {hit.case_number}</span>
{hit.status && (
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
{hit.status}
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">
{hit.title}
{hit.property_address && <span> · {hit.property_address}</span>}
</div>
</Link>
);
}
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 (
<Link
href={href}
onClick={onSelect}
role="option"
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
>
<div className="flex items-baseline justify-between gap-3">
<span className="font-medium text-sm line-clamp-1">הלכה {hit.rule_statement}</span>
<span className="text-[10px] text-gold-deep tabular-nums">{pct}%</span>
</div>
<div className="text-xs text-muted-foreground truncate">
{hit.case_name} · {hit.case_number}
</div>
</Link>
);
}
return (
<Link
href={href}
onClick={onSelect}
role="option"
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
>
<div className="flex items-baseline justify-between gap-3">
<span className="font-medium text-sm">פסקה</span>
<span className="text-[10px] text-gold-deep tabular-nums">{pct}%</span>
</div>
<div className="text-xs text-muted-foreground line-clamp-2">
{hit.content.slice(0, 160)}
</div>
<div className="text-[11px] text-muted-foreground/70">
{hit.case_name} · {hit.case_number}
</div>
</Link>
);
}
function DocumentRow({ hit, onSelect }: { hit: DocumentHit; onSelect: () => void }) {
const pct = Math.round(hit.score * 100);
return (
<Link
href={`/cases/${encodeURIComponent(hit.case_number)}`}
onClick={onSelect}
role="option"
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
>
<div className="flex items-baseline justify-between gap-3">
<span className="font-medium text-sm truncate">{hit.document}</span>
<span className="text-[10px] text-gold-deep tabular-nums">{pct}%</span>
</div>
<div className="text-xs text-muted-foreground line-clamp-2">
{hit.content.slice(0, 160)}
</div>
<div className="text-[11px] text-muted-foreground/70">
ערר {hit.case_number}
{hit.page != null && <span> · עמ׳ {hit.page}</span>}
</div>
</Link>
);
}

View File

@@ -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<DocumentHit[] | { message: string }>(
`/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,
};
}

View File

@@ -1387,6 +1387,55 @@ async def api_case_search(case_number: str, query: str, limit: int = 10):
return {"message": result} 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") @app.get("/api/cases/{case_number}/template")
async def api_case_template(case_number: str): async def api_case_template(case_number: str):
"""Get outcome-aware decision template for a case.""" """Get outcome-aware decision template for a case."""