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

@@ -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,
};
}