diff --git a/docs/process-hardening.md b/docs/process-hardening.md index a4a678c7df..9f70b422d3 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -113,3 +113,9 @@ For each: trace which module-scope helpers/icons/types it uses; move solely-cons - `GlobalMockupSearchShell` (aka `GlobalSearchShell`, used by the `forms`/`services`/`favourites`/`medications` layouts) wrapped `GlobalMockupSearchShellClient` in a `` whose **fallback also rendered `props.children` inside `#main-content`** — the same subtree the client body renders. Because `useSearchParams()` forces that boundary to the fallback on the server, the page subtree was emitted twice and both copies could persist, producing duplicate `id="main-content"` and duplicate `data-testid` on every shell page. It surfaced as `ui-smoke.spec.ts:1103` failing with a strict-mode violation (two `data-testid="acamprosate-medication-page"` `
` elements on `/medications/acamprosate`). - Fix: the Suspense fallback renders a **neutral placeholder only** — never `props.children`. Rule: do not render the resolved content inside its own Suspense fallback; the fallback is a loading state, not a second copy of the page. + +## Clinical registry tables applied live (2026-07-03) + +- **Migration `20260703020000_clinical_registry_records.sql` (applied live 2026-07-03 with explicit user approval)** created `public.clinical_registry_records` (owner-scoped structured Services/Forms records — 30 cols, JSONB render payloads, conservative `source_status`/`validation_status` governance columns) and the `public.clinical_registry_record_sources` join table (record ↔ verifying corpus document, FK cascade). Both are service-role-only RLS (enabled + revoked from anon/authenticated + a single `for all to service_role` policy); ownership is enforced in the API layer, matching the documents model. Verified post-apply: both tables present with correct columns, RLS enabled, 0 rows; `get_advisors(security)` returns no lints. `supabase/schema.sql` mirrors the migration and `tests/supabase-schema.test.ts` asserts the shape. +- **Version-recording nuance:** the MCP `apply_migration` timestamps the history row in UTC, so live recorded the migration as version `20260702183308` (name `clinical_registry_records`) while the repo file is `20260703020000_...` (Australia/Perth date). Because the migration is fully idempotent (`create table/index if not exists`, `drop trigger/policy if exists` + create), a later `supabase db push` re-applying the repo file is a harmless no-op that only adds a second history row — the same known duplicate-version churn already present in live history. Do not rewrite history to reconcile; treat as a caution. +- **Remaining step (user):** seed the registry per owner with `npm run registry:seed -- --owner-id --write --confirm`. Until seeded, authenticated users see the honest empty-registry state; demo/env-less deployments are unaffected. See [[PR #209]]. diff --git a/package.json b/package.json index 5dd0f81261..cbfb2f301b 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "check:supabase-project": "tsx scripts/check-supabase-project.ts", "check:indexing": "tsx scripts/check-indexing.ts", "recover:ingestion": "tsx scripts/recover-ingestion-queue.ts", + "registry:seed": "tsx scripts/seed-registry-records.ts", "reindex": "tsx scripts/reindex.ts", "reindex:health": "tsx scripts/reindex-health.ts", "reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts", diff --git a/scripts/seed-registry-records.ts b/scripts/seed-registry-records.ts new file mode 100644 index 0000000000..6375ae04d9 --- /dev/null +++ b/scripts/seed-registry-records.ts @@ -0,0 +1,119 @@ +import { loadEnvConfig } from "@next/env"; + +import { confirm } from "./cli-utils"; +import { recordToRow, type RegistryRecordKind } from "@/lib/registry-records"; +import { serviceRecords } from "@/lib/services"; +import { formRecords } from "@/lib/forms"; +import type { ServiceRecord } from "@/lib/services"; + +loadEnvConfig(process.cwd()); + +type SeedArgs = { + ownerId?: string; + kind: RegistryRecordKind | "all"; + write: boolean; + confirmed: boolean; +}; + +async function loadAdminClient() { + const { createAdminClient } = await import("@/lib/supabase/admin"); + return createAdminClient(); +} + +function parseArgs(argv: string[]): SeedArgs { + const args: SeedArgs = { + ownerId: process.env.LOCAL_NO_AUTH_OWNER_ID, + kind: "all", + write: false, + confirmed: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--write") { + args.write = true; + continue; + } + if (token === "--confirm") { + args.confirmed = true; + continue; + } + if (token === "--owner-id") { + args.ownerId = argv[index + 1]; + index += 1; + continue; + } + if (token === "--kind") { + const value = argv[index + 1]; + if (value !== "service" && value !== "form" && value !== "all") { + throw new Error(`Invalid --kind value: ${value ?? "(missing)"}. Use service | form | all.`); + } + args.kind = value; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + + return args; +} + +function seedSets(kind: SeedArgs["kind"]): Array<{ kind: RegistryRecordKind; records: ServiceRecord[] }> { + const sets: Array<{ kind: RegistryRecordKind; records: ServiceRecord[] }> = []; + if (kind === "service" || kind === "all") sets.push({ kind: "service", records: serviceRecords }); + if (kind === "form" || kind === "all") sets.push({ kind: "form", records: formRecords }); + return sets; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.ownerId) { + throw new Error("No owner id. Pass --owner-id or set LOCAL_NO_AUTH_OWNER_ID."); + } + + const sets = seedSets(args.kind); + const rows = sets.flatMap((set) => set.records.map((record) => recordToRow(record, args.ownerId!, set.kind))); + + console.log(`[registry:seed] owner ${args.ownerId}`); + for (const row of rows) { + console.log( + ` ${row.kind.padEnd(7)} ${String(row.slug).padEnd(42)} source_status=${row.source_status} validation_status=${row.validation_status}`, + ); + } + console.log( + `[registry:seed] ${rows.length} records (${sets.map((s) => `${s.records.length} ${s.kind}`).join(", ")})`, + ); + + if (!args.write) { + console.log("[registry:seed] Dry run. Re-run with --write --confirm to upsert."); + return; + } + if (!args.confirmed) { + const proceed = await confirm(`Upsert ${rows.length} registry records for owner ${args.ownerId}?`); + if (!proceed) { + console.log("[registry:seed] Aborted."); + return; + } + } + + const supabase = await loadAdminClient(); + const { error } = await supabase.from("clinical_registry_records").upsert(rows, { onConflict: "owner_id,kind,slug" }); + if (error) { + throw new Error(`Upsert failed: ${error.message}`); + } + + const { count, error: countError } = await supabase + .from("clinical_registry_records") + .select("id", { count: "exact", head: true }) + .eq("owner_id", args.ownerId); + if (countError) { + console.warn(`[registry:seed] Upsert succeeded but count check failed: ${countError.message}`); + } else { + console.log(`[registry:seed] Done. Owner now has ${count ?? "?"} registry records.`); + } +} + +main().catch((error) => { + console.error(`[registry:seed] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts new file mode 100644 index 0000000000..18ef828948 --- /dev/null +++ b/src/app/api/registry/records/[slug]/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { jsonError } from "@/lib/http"; +import { getFormRecord } from "@/lib/forms"; +import { + normalizeRegistrySlug, + rowGovernance, + rowToServiceRecord, + type RegistryRecordRow, +} from "@/lib/registry-records"; +import { getServiceRecord } from "@/lib/services"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { parseRequestQuery } from "@/lib/validation/query"; + +export const runtime = "nodejs"; + +const registryDetailQuerySchema = z.object({ + kind: z.enum(["service", "form"]), +}); + +function registryResponse(payload: Record, init?: { status?: number }) { + return NextResponse.json(payload, { + status: init?.status ?? 200, + headers: { "Cache-Control": "private, no-store" }, + }); +} + +function notFoundResponse(slug: string) { + return registryResponse({ error: `No registry record found for "${slug}".` }, { status: 404 }); +} + +export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) { + try { + const { slug } = await context.params; + const normalizedSlug = normalizeRegistrySlug(slug); + const { kind } = parseRequestQuery(request, registryDetailQuerySchema, "Invalid registry detail query."); + + if (isDemoMode()) { + const record = kind === "form" ? getFormRecord(normalizedSlug) : getServiceRecord(normalizedSlug); + if (!record) return notFoundResponse(normalizedSlug); + return registryResponse({ record, linkedDocuments: [], demoMode: true }); + } + + const supabase = createAdminClient(); + const user = await requireAuthenticatedUser(request, supabase); + + const rateLimit = await consumeApiRateLimit({ + supabase, + ownerId: user.id, + bucket: "registry", + allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + }); + if (rateLimit.limited) { + return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit); + } + + const { data, error } = await supabase + .from("clinical_registry_records") + .select("*") + .eq("owner_id", user.id) + .eq("kind", kind) + .eq("slug", normalizedSlug) + .maybeSingle(); + if (error) throw new Error(error.message); + if (!data) return notFoundResponse(normalizedSlug); + + const row = data as RegistryRecordRow; + + const { data: links, error: linksError } = await supabase + .from("clinical_registry_record_sources") + .select("document_id, note") + .eq("owner_id", user.id) + .eq("record_id", row.id); + if (linksError) throw new Error(linksError.message); + + let linkedDocuments: Array<{ id: string; title: string; file_name: string; status: string }> = []; + const documentIds = (links ?? []).map((link) => link.document_id); + if (documentIds.length > 0) { + const { data: documents, error: documentsError } = await supabase + .from("documents") + .select("id, title, file_name, status") + .eq("owner_id", user.id) + .in("id", documentIds); + if (documentsError) throw new Error(documentsError.message); + linkedDocuments = (documents ?? []) as typeof linkedDocuments; + } + + return registryResponse({ + record: rowToServiceRecord(row), + governance: rowGovernance(row), + linkedDocuments, + }); + } catch (error) { + if (error instanceof AuthenticationError) { + return unauthorizedResponse(); + } + return jsonError(error); + } +} diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts new file mode 100644 index 0000000000..70f7cc79ef --- /dev/null +++ b/src/app/api/registry/records/route.ts @@ -0,0 +1,131 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { jsonError } from "@/lib/http"; +import { rankFormRecords, formRecords } from "@/lib/forms"; +import { + deriveGovernanceColumns, + rowGovernance, + rowToServiceRecord, + type RegistryRecordKind, + type RegistryRecordRow, +} from "@/lib/registry-records"; +import { rankServiceRecords, serviceRecords, type ServiceRecord, type ServiceSearchMatch } from "@/lib/services"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; + +export const runtime = "nodejs"; + +// The list is a small curated per-owner set that clients fetch in full and +// rank client-side, so the whole set must be returned (never truncated) or +// rows past the cap become invisible to Services/Forms search and undercount +// the home footers. This ceiling is a defensive bound well above realistic +// registry sizes; `limit` only bounds the ranked `matches` for a `q` query. +const REGISTRY_MAX_RECORDS = 500; + +const registryListQuerySchema = z.object({ + kind: z.enum(["service", "form"]), + q: z + .string() + .trim() + .max(200) + .optional() + .transform((value) => (value ? value : undefined)), + limit: queryInteger({ fallback: 100, min: 1, max: 200 }), +}); + +function rankRecords(kind: RegistryRecordKind, records: ServiceRecord[], query: string, limit: number) { + return kind === "form" ? rankFormRecords(records, query, limit) : rankServiceRecords(records, query, limit); +} + +function registryResponse(payload: Record) { + return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); +} + +function matchesPayload(matches: ServiceSearchMatch[]) { + return matches.map((match) => ({ record: match.service, score: match.score, reasons: match.reasons })); +} + +export async function GET(request: Request) { + try { + const { kind, q, limit } = parseRequestQuery(request, registryListQuerySchema, "Invalid registry query."); + + if (isDemoMode()) { + const records = kind === "form" ? formRecords : serviceRecords; + const governance = Object.fromEntries( + records.map((record) => { + const derived = deriveGovernanceColumns(record); + return [record.slug, { sourceStatus: derived.source_status, validationStatus: derived.validation_status }]; + }), + ); + return registryResponse({ + records, + matches: q ? matchesPayload(rankRecords(kind, records, q, limit)) : undefined, + total: records.length, + governance, + demoMode: true, + }); + } + + const supabase = createAdminClient(); + const user = await requireAuthenticatedUser(request, supabase); + + const rateLimit = await consumeApiRateLimit({ + supabase, + ownerId: user.id, + bucket: "registry", + allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + }); + if (rateLimit.limited) { + return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit); + } + + const { data, error } = await supabase + .from("clinical_registry_records") + .select("*") + .eq("owner_id", user.id) + .eq("kind", kind) + .order("title") + .limit(REGISTRY_MAX_RECORDS); + if (error) throw new Error(error.message); + + const rows = (data ?? []) as RegistryRecordRow[]; + const records = rows.map(rowToServiceRecord); + const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])); + + const recordIds = rows.map((row) => row.id); + const linkedDocumentIdsByRecordId: Record = {}; + if (recordIds.length > 0) { + const { data: links, error: linksError } = await supabase + .from("clinical_registry_record_sources") + .select("record_id, document_id") + .eq("owner_id", user.id) + .in("record_id", recordIds); + if (linksError) throw new Error(linksError.message); + for (const link of links ?? []) { + const existing = linkedDocumentIdsByRecordId[link.record_id] ?? []; + existing.push(link.document_id); + linkedDocumentIdsByRecordId[link.record_id] = existing; + } + } + const linkedDocumentIdsBySlug = Object.fromEntries( + rows.map((row) => [row.slug, linkedDocumentIdsByRecordId[row.id] ?? []]), + ); + + return registryResponse({ + records, + matches: q ? matchesPayload(rankRecords(kind, records, q, limit)) : undefined, + total: rows.length, + governance: governanceBySlug, + linkedDocumentIds: linkedDocumentIdsBySlug, + }); + } catch (error) { + if (error instanceof AuthenticationError) { + return unauthorizedResponse(); + } + return jsonError(error); + } +} diff --git a/src/app/forms/[slug]/page.tsx b/src/app/forms/[slug]/page.tsx index 1d509c6dcc..1f30542e43 100644 --- a/src/app/forms/[slug]/page.tsx +++ b/src/app/forms/[slug]/page.tsx @@ -1,32 +1,17 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { FormDetailPage } from "@/components/forms/form-detail-page"; -import { getFormRecord, formStaticParams } from "@/lib/forms"; +import { FormDetailClient } from "@/components/forms/form-detail-client"; type FormRouteProps = { params: Promise<{ slug: string }>; }; -export function generateStaticParams() { - return formStaticParams(); -} - -export async function generateMetadata({ params }: FormRouteProps): Promise { - const { slug } = await params; - const form = getFormRecord(slug); - if (!form) return { title: "Form not found - Clinical KB" }; - - return { - title: `${form.title} - Forms - Clinical KB`, - description: form.subtitle ?? "Psychiatry form and workflow details.", - }; -} +export const metadata: Metadata = { + title: "Form record - Forms - Clinical KB", + description: "Psychiatry form and workflow details.", +}; export default async function FormRoute({ params }: FormRouteProps) { const { slug } = await params; - const form = getFormRecord(slug); - if (!form) return notFound(); - - return ; + return ; } diff --git a/src/app/services/[slug]/page.tsx b/src/app/services/[slug]/page.tsx index 0b1f28dd0b..9f934c0b97 100644 --- a/src/app/services/[slug]/page.tsx +++ b/src/app/services/[slug]/page.tsx @@ -1,32 +1,17 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { ServiceDetailPage } from "@/components/ServiceDetailPage"; -import { getServiceRecord, serviceStaticParams } from "@/lib/services"; +import { ServiceDetailClient } from "@/components/services/service-detail-client"; type ServiceRouteProps = { params: Promise<{ slug: string }>; }; -export function generateStaticParams() { - return serviceStaticParams(); -} - -export async function generateMetadata({ params }: ServiceRouteProps): Promise { - const { slug } = await params; - const service = getServiceRecord(slug); - if (!service) return { title: "Service record not found - Clinical KB" }; - - return { - title: `${service.title} - Services - Clinical KB`, - description: service.subtitle ?? "Clinical service record details and referral information.", - }; -} +export const metadata: Metadata = { + title: "Service record - Services - Clinical KB", + description: "Clinical service record details and referral information.", +}; export default async function ServiceRoute({ params }: ServiceRouteProps) { const { slug } = await params; - const service = getServiceRecord(slug); - if (!service) return notFound(); - - return ; + return ; } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index e2f0295a49..3ded28e06c 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -215,8 +215,9 @@ import { type AppModeId, type AppModeSearchKind, } from "@/lib/app-modes"; -import { searchFormRecords } from "@/lib/forms"; -import { searchServiceRecords } from "@/lib/services"; +import { rankFormRecords } from "@/lib/forms"; +import { rankServiceRecords } from "@/lib/services"; +import { useRegistryRecords } from "@/lib/use-registry-records"; import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; import { @@ -368,6 +369,7 @@ function compactScopeFilters(filters: SearchScopeFilters) { if (filters.locality) next.locality = filters.locality; if (filters.importBatchIds?.length) next.importBatchIds = filters.importBatchIds; if (filters.collections?.length) next.collections = filters.collections; + if (filters.labelTypesAny?.length) next.labelTypesAny = filters.labelTypesAny; return next; } @@ -3772,13 +3774,19 @@ export function ClinicalDashboard({ const activeModeSearch = appModeSearchConfig(searchMode); const activeModeResultKind = appModeResultKind(searchMode); const requestQueryMode = appModeQueryMode(searchMode, queryMode); + // Record matches come from the owner-scoped registry API (mock fixtures in + // demo mode); ranking stays client-side so live-typing behaviour is + // unchanged and the registry is fetched once per active mode. + const registryRecords = useRegistryRecords(searchMode === "forms" ? "form" : "service", { + enabled: searchMode === "services" || searchMode === "forms", + }); const serviceSearchMatches = useMemo( - () => (searchMode === "services" ? searchServiceRecords(query) : []), - [query, searchMode], + () => (searchMode === "services" ? rankServiceRecords(registryRecords.records, query) : []), + [query, searchMode, registryRecords.records], ); const formSearchMatches = useMemo( - () => (searchMode === "forms" ? searchFormRecords(query) : []), - [query, searchMode], + () => (searchMode === "forms" ? rankFormRecords(registryRecords.records, query) : []), + [query, searchMode, registryRecords.records], ); const recordSearchMatches = useMemo( () => (searchMode === "forms" ? formSearchMatches : searchMode === "services" ? serviceSearchMatches : []), @@ -4707,6 +4715,11 @@ export function ClinicalDashboard({ const modeSearch = appModeSearchConfig(targetMode); const targetQueryMode = appModeQueryMode(targetMode, queryMode); const isDifferentialsMode = modeSearch.resultKind === "differentials"; + // Note: no automatic mode-default label scope for Services/Forms. Applying + // one on every search routed resolveSearchScope's label path over the whole + // library, whose single `document_labels.in()` request produces an + // over-long PostgREST URL that fails on large corpora. Corpus search runs + // unscoped (like Documents); users opt into label filters explicitly. setSearchMode(targetMode); setQuery(trimmedQuery); @@ -5726,7 +5739,6 @@ export function ClinicalDashboard({ query={query} loading={loading} documentMatches={documentMatches} - documentCount={indexedDocumentTotal} realDataReady={canRunSearch} authUnavailable={!clientDemoMode && !canUsePrivateApis} apiUnavailable={apiUnavailable} diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index c43c79bd60..470a4e2b42 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -511,8 +511,6 @@ function UrgencyCard({ results }: { results: DifferentialResult[] }) { } function SourceStatusCard({ sourceCount }: { sourceCount: number }) { - const workflow = acuteConfusionPresentationWorkflow; - return (

@@ -522,19 +520,24 @@ function SourceStatusCard({ sourceCount }: { sourceCount: number }) {

- Local only + Library matches + + + {sourceCount.toLocaleString()} source{sourceCount === 1 ? "" : "s"} - {sourceCount.toLocaleString()} sources

- {workflow.sourceStatus.label} + Ranked diagnoses - 312 sources + Demonstration

-

{workflow.sourceStatus.version}

+

+ Matched sources come from your indexed document library. The diagnosis ranking is synthetic demonstration + content. +

); } @@ -570,13 +573,11 @@ function SearchResultsView({ query, loading, documentMatches, - documentCount, onRunSearch, }: { query: string; loading: boolean; documentMatches?: DocumentMatch[]; - documentCount?: number; onRunSearch?: (query: string) => void; }) { const results = useMemo(() => buildDifferentialResults(), []); @@ -585,7 +586,9 @@ function SearchResultsView({ ); const best = results[0]; const selectedCount = selectedIds.size; - const reviewedSourceCount = documentCount && documentCount > 0 ? documentCount : (documentMatches?.length ?? 0); + // Count the sources that actually matched this search, never the whole + // indexed library — the surrounding copy states these reflect real matches. + const reviewedSourceCount = documentMatches?.length ?? 0; function toggleSelected(id: string) { setSelectedIds((current) => { @@ -606,16 +609,26 @@ function SearchResultsView({ data-testid="differentials-search-results" className="mx-auto grid w-full max-w-[86rem] gap-4 overflow-x-hidden px-3 sm:px-4 lg:px-0" > +

+ + + The ranked diagnoses below are synthetic demonstration content, not clinically authored guidance. Source + counts reflect real matches from your indexed library. + +

- - - Local results only + + + Demonstration ranking - Reviewed content prioritised. Review before use. + Synthetic content. Not for clinical use.

Diagnosis pages (ranked) @@ -712,7 +725,7 @@ function SearchResultsView({ type="button" className="hidden min-h-11 w-full items-center justify-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-4 text-sm font-extrabold text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)] lg:inline-flex" > - View all results (14) + View all demonstration results ({results.length}) @@ -751,7 +764,6 @@ export function DifferentialsHome({ query, loading, documentMatches, - documentCount, onQueryChange, onSuggestedSearch, onRunSearch, @@ -762,7 +774,6 @@ export function DifferentialsHome({ query: string; loading: boolean; documentMatches?: DocumentMatch[]; - documentCount?: number; realDataReady?: boolean; authUnavailable?: boolean; apiUnavailable?: boolean; @@ -820,7 +831,6 @@ export function DifferentialsHome({ query={trimmedQuery} loading={loading} documentMatches={documentMatches} - documentCount={documentCount} onRunSearch={onRunSearch} /> ); @@ -837,13 +847,13 @@ export function DifferentialsHome({ > No source-backed matches for “{trimmedQuery}” yet. Run the search or refine the presentation to - see reviewed differentials. + see the demonstration ranking alongside matches from your library.

) : null} handleAction(action), disabled: loading, }))} - pillsTitle={hasEvidenceMatches ? "Reviewed matches" : "Recent work"} + pillsTitle={hasEvidenceMatches ? "Library matches" : "Recent work"} pillsAction={