diff --git a/docs/site-map.md b/docs/site-map.md index 349275b1c..33f3ad27e 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -10,6 +10,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. - `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. - `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. +- `/favourites/legacy` - Route discovered from app directory Source: `src/app/favourites/legacy/page.tsx`. - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. - `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. @@ -26,6 +27,19 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. +## Mode page index + +| Mode | Home page | Search/results page | Information/detail pages | +| ------------- | -------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | +| Documents | `/?mode=documents` | `/?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/[id]` document viewer and in-document search. | +| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | +| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | +| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | +| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | +| Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | +| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | `/applications` launcher and tool detail panels inside tools mode. | + ## Registry-backed routes - `/services/[slug]` - Registry-backed service detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records. @@ -83,10 +97,15 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/document-search-evidence-lens` - Route discovered from app directory Source: `src/app/mockups/document-search-evidence-lens/page.tsx`. - `/mockups/document-search-triage-board` - Route discovered from app directory Source: `src/app/mockups/document-search-triage-board/page.tsx`. - `/mockups/document-search/source` - Route discovered from app directory Source: `src/app/mockups/document-search/source/page.tsx`. +- `/mockups/document-search/source-overlays` - Route discovered from app directory Source: `src/app/mockups/document-search/source-overlays/page.tsx`. +- `/mockups/document-search/source/evidence` - Route discovered from app directory Source: `src/app/mockups/document-search/source/evidence/page.tsx`. +- `/mockups/favourites-command-console` - Route discovered from app directory Source: `src/app/mockups/favourites-command-console/page.tsx`. - `/mockups/favourites-command-desk` - Route discovered from app directory Source: `src/app/mockups/favourites-command-desk/page.tsx`. - `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. - `/mockups/favourites-library-view` - Route discovered from app directory Source: `src/app/mockups/favourites-library-view/page.tsx`. +- `/mockups/favourites-review-console` - Route discovered from app directory Source: `src/app/mockups/favourites-review-console/page.tsx`. - `/mockups/favourites-set-board` - Route discovered from app directory Source: `src/app/mockups/favourites-set-board/page.tsx`. +- `/mockups/favourites-set-navigator` - Route discovered from app directory Source: `src/app/mockups/favourites-set-navigator/page.tsx`. - `/mockups/medication-prescribing` - Route discovered from app directory Source: `src/app/mockups/medication-prescribing/page.tsx`. - `/mockups/mode-dropdown` - Route discovered from app directory Source: `src/app/mockups/mode-dropdown/page.tsx`. - `/mockups/recent-searches-bottom` - Route discovered from app directory Source: `src/app/mockups/recent-searches-bottom/page.tsx`. @@ -96,7 +115,10 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/tools-action-workbench` - Route discovered from app directory Source: `src/app/mockups/tools-action-workbench/page.tsx`. - `/mockups/tools-clinical-lanes` - Route discovered from app directory Source: `src/app/mockups/tools-clinical-lanes/page.tsx`. - `/mockups/tools-command-center` - Route discovered from app directory Source: `src/app/mockups/tools-command-center/page.tsx`. +- `/mockups/tools-split-clinical-brief` - Route discovered from app directory Source: `src/app/mockups/tools-split-clinical-brief/page.tsx`. +- `/mockups/tools-split-compact-sheet` - Route discovered from app directory Source: `src/app/mockups/tools-split-compact-sheet/page.tsx`. - `/mockups/tools-split-pane` - Route discovered from app directory Source: `src/app/mockups/tools-split-pane/page.tsx`. +- `/mockups/tools-split-safety-deck` - Route discovered from app directory Source: `src/app/mockups/tools-split-safety-deck/page.tsx`. - `/mockups/tools-task-directory` - Route discovered from app directory Source: `src/app/mockups/tools-task-directory/page.tsx`. - `/mockups/tools-workflow-board` - Route discovered from app directory Source: `src/app/mockups/tools-workflow-board/page.tsx`. diff --git a/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md b/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md new file mode 100644 index 000000000..9fbe85c15 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md @@ -0,0 +1,1236 @@ +# Public Anonymous Access and Rate Limiting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the forced sign-in/authorization barrier so anonymous users can run live searches, generate answers, browse/view source documents, and use the app without logging in, while protecting costly APIs with server-side rate limits. + +**Architecture:** Keep Supabase auth optional instead of required for public read/search/answer flows. Authenticated users keep owner-scoped behavior; anonymous users use a privacy-preserving anonymous caller key for rate limits and public/global search behavior. Uploads and owner-required mutations must either use a configured shared public workspace owner or stay disabled with setup copy that does not ask users to sign in. + +**Tech Stack:** Next.js App Router API routes, React 19 client UI, Supabase service-role server client, existing `consume_api_rate_limit` RPC, new anonymous rate-limit RPC/table, Vitest route tests, existing `npm run verify:cheap` gate. + +## Global Constraints + +- Do not require Supabase Auth for live `/api/search`, `/api/answer`, or `/api/answer/stream` requests. +- Fix the visible UI error: “Search request was not authorized by the server.” +- Remove the sign-in panel/gate from the main website experience. +- Add rate-limit-only abuse protection, per user when authenticated and per anonymous caller when signed out. +- Do not add captcha, Turnstile, reCAPTCHA, or new third-party dependencies. +- Do not expose service-role keys or private env values to the browser. +- Preserve existing authenticated behavior where a real Supabase session is present. +- Keep destructive document management protected unless a shared public owner is explicitly configured. +- Run `npm run verify:cheap` after implementation; for UI changes also run `npm run ensure` and `npm run verify:ui` if feasible. +- Do not commit unless the user explicitly requests it. + +--- + +## File Structure + +- Modify: `src/lib/env.ts` + - Add optional public workspace owner env support for anonymous upload/owner-required actions. +- Modify: `src/lib/supabase/auth.ts` + - Add optional auth resolution instead of throwing for missing tokens. +- Create: `src/lib/public-api-access.ts` + - Centralize anonymous caller key derivation, public access context, and public owner resolution. +- Modify: `src/lib/api-rate-limit.ts` + - Add anonymous subject rate limiting while preserving existing authenticated owner rate limiting. +- Create: `supabase/migrations/YYYYMMDDHHMMSS_public_api_rate_limit_subjects.sql` + - Add durable anonymous subject rate-limit storage and RPC. +- Modify: `supabase/schema.sql` + - Mirror the new durable anonymous rate-limit table/function. +- Modify: `src/app/api/search/route.ts` + - Stop requiring auth; apply public access context and anonymous rate limit. +- Modify: `src/app/api/answer/route.ts` + - Stop requiring auth; apply public access context and anonymous rate limit. +- Modify: `src/app/api/answer/stream/route.ts` + - Stop requiring auth; apply public access context and anonymous rate limit. +- Modify: `src/app/api/documents/route.ts` + - Allow anonymous document listing for public/global search previews. +- Modify likely source-preview routes after inspection during execution: + - `src/app/api/documents/[id]/route.ts` + - `src/app/api/documents/[id]/search/route.ts` + - `src/app/api/documents/[id]/signed-url/route.ts` + - `src/app/api/images/[id]/signed-url/route.ts` +- Modify: `src/app/api/upload/route.ts` + - If public upload is desired, use shared public owner; otherwise return a non-auth setup message. +- Modify: `src/components/ClinicalDashboard.tsx` + - Remove main-page auth gate and make `canRunSearch` true when setup is ready even without auth. +- Modify: `src/components/clinical-dashboard/auth-panel.tsx` + - Stop rendering forced sign-in for the public flow, or leave as optional account panel only. +- Modify: `src/components/clinical-dashboard/document-search-results.tsx` + - Replace “Sign in...” copy with setup/rate-limit copy. +- Modify: `src/components/DocumentViewer.tsx` + - Let anonymous users open source previews without auth when the route supports it. +- Modify: `tests/private-access-routes.test.ts` + - Update old 401 expectations for search/answer/doc preview to anonymous success/rate-limit behavior. +- Add/modify focused tests as needed in `tests/api-validation-contract.test.ts` or a new `tests/public-access-rate-limit.test.ts` if the existing private access test becomes too large. + +--- + +### Task 1: Add Public Access Context and Optional Auth + +**Files:** + +- Modify: `src/lib/supabase/auth.ts:32-83` +- Create: `src/lib/public-api-access.ts` +- Modify: `src/lib/env.ts:1-20`, `src/lib/env.ts:150-179` +- Test: `tests/private-access-routes.test.ts` + +**Interfaces:** + +- Consumes: existing `requireAuthenticatedUser(request, supabase)`. +- Produces: + - `getOptionalAuthenticatedUser(request: Request, supabase: AdminClient): Promise` + - `anonymousApiSubjectKey(request: Request): string` + - `publicAccessContext(request: Request, supabase: AdminClient): Promise<{ ownerId?: string; authenticated: boolean; rateLimitSubject: { kind: "owner"; ownerId: string } | { kind: "anonymous"; subjectKey: string } }>` + - `publicWorkspaceOwnerId(): string | null` + +- [ ] **Step 1: Write failing tests for optional auth and anonymous key stability** + +Add tests near the auth/no-auth cases in `tests/private-access-routes.test.ts`: + +```ts +it("derives a stable anonymous rate-limit subject without requiring Supabase auth", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const { publicAccessContext } = await import("../src/lib/public-api-access"); + + const first = await publicAccessContext( + request("/api/search", { + headers: { + "x-forwarded-for": "203.0.113.44, 10.0.0.1", + "user-agent": "Vitest Browser", + }, + }), + client as never, + ); + const second = await publicAccessContext( + request("/api/search", { + headers: { + "x-forwarded-for": "203.0.113.44, 10.0.0.1", + "user-agent": "Vitest Browser", + }, + }), + client as never, + ); + + expect(first.authenticated).toBe(false); + expect(first.ownerId).toBeUndefined(); + expect(first.rateLimitSubject.kind).toBe("anonymous"); + expect(first.rateLimitSubject).toEqual(second.rateLimitSubject); + expect(client.auth.getUser).not.toHaveBeenCalled(); +}); + +it("uses authenticated owner rate limits when a valid bearer token is present", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const { publicAccessContext } = await import("../src/lib/public-api-access"); + + const context = await publicAccessContext(authenticatedRequest("/api/search"), client as never); + + expect(context).toMatchObject({ + authenticated: true, + ownerId: userId, + rateLimitSubject: { kind: "owner", ownerId: userId }, + }); + expect(client.auth.getUser).toHaveBeenCalledTimes(1); +}); +``` + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "anonymous rate-limit subject|authenticated owner rate limits" +``` + +Expected: FAIL because `src/lib/public-api-access.ts` and optional auth helpers do not exist. + +- [ ] **Step 3: Implement optional auth helper** + +In `src/lib/supabase/auth.ts`, keep `requireAuthenticatedUser` unchanged and add: + +```ts +export async function getOptionalAuthenticatedUser( + request: Request, + supabase: AdminClient, +): Promise { + const token = extractSessionAccessToken(request); + if (!token) return null; + + const { data, error } = await supabase.auth.getUser(token); + const userId = data.user?.id; + if (error || !userId) { + throw new AuthenticationError(); + } + + return { id: userId }; +} +``` + +- [ ] **Step 4: Add public env fields** + +In `src/lib/env.ts`, add optional keys to the env schema near the existing local no-auth owner fields: + +```ts +PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(), +NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(), +``` + +Add helpers near `isLocalNoAuthMode()`: + +```ts +export function publicWorkspaceOwnerId() { + return env.PUBLIC_WORKSPACE_OWNER_ID?.trim() || null; +} + +export function publicUploadsEnabled() { + return process.env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true"; +} +``` + +- [ ] **Step 5: Create public access helper** + +Create `src/lib/public-api-access.ts`: + +```ts +import { createHash } from "node:crypto"; +import { publicWorkspaceOwnerId } from "@/lib/env"; +import type { createAdminClient } from "@/lib/supabase/admin"; +import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth"; + +type AdminClient = ReturnType; + +export type RateLimitSubject = { kind: "owner"; ownerId: string } | { kind: "anonymous"; subjectKey: string }; + +function firstForwardedIp(value: string | null) { + return value?.split(",")[0]?.trim() || ""; +} + +function requestIpSignal(request: Request) { + return ( + firstForwardedIp(request.headers.get("cf-connecting-ip")) || + firstForwardedIp(request.headers.get("x-forwarded-for")) || + firstForwardedIp(request.headers.get("x-real-ip")) || + "unknown-ip" + ); +} + +export function anonymousApiSubjectKey(request: Request) { + const userAgent = request.headers.get("user-agent")?.slice(0, 180) || "unknown-agent"; + const acceptLanguage = request.headers.get("accept-language")?.slice(0, 80) || "unknown-language"; + const raw = [requestIpSignal(request), userAgent, acceptLanguage].join("|"); + return `anon:${createHash("sha256").update(raw).digest("hex")}`; +} + +export async function publicAccessContext(request: Request, supabase: AdminClient) { + const user = await getOptionalAuthenticatedUser(request, supabase); + if (user) { + return { + ownerId: user.id, + authenticated: true, + rateLimitSubject: { kind: "owner", ownerId: user.id } satisfies RateLimitSubject, + }; + } + + return { + ownerId: undefined, + authenticated: false, + rateLimitSubject: { kind: "anonymous", subjectKey: anonymousApiSubjectKey(request) } satisfies RateLimitSubject, + }; +} + +export { publicWorkspaceOwnerId }; +``` + +- [ ] **Step 6: Run the focused tests and verify they pass** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "anonymous rate-limit subject|authenticated owner rate limits" +``` + +Expected: PASS. + +--- + +### Task 2: Add Durable Anonymous Rate Limiting + +**Files:** + +- Modify: `src/lib/api-rate-limit.ts:1-160` +- Create: `supabase/migrations/YYYYMMDDHHMMSS_public_api_rate_limit_subjects.sql` +- Modify: `supabase/schema.sql` +- Test: `tests/private-access-routes.test.ts` + +**Interfaces:** + +- Consumes: `RateLimitSubject` from `src/lib/public-api-access.ts`. +- Produces: + - `consumeSubjectApiRateLimit(args: { supabase: SupabaseAdmin; subject: RateLimitSubject; bucket: ApiRateLimitBucket; limit?: number; windowSeconds?: number; allowInMemoryFallbackOnUnavailable?: boolean }): Promise` + +- [ ] **Step 1: Write failing anonymous limiter test** + +Add near existing rate-limit tests in `tests/private-access-routes.test.ts`: + +```ts +it("rate limits anonymous answer requests by anonymous subject key", async () => { + const client = createSupabaseMock(); + client.rpc.mockImplementation(async (name: string, args?: Record) => + name === "consume_api_subject_rate_limit" && args?.p_bucket === "answer" + ? { data: [rateLimitRow({ limited: true, limit_value: 10, remaining: 0 })], error: null } + : ok([]), + ); + mockRuntime(client); + const { consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit"); + + const result = await consumeSubjectApiRateLimit({ + supabase: client as never, + subject: { kind: "anonymous", subjectKey: "anon:test-key" }, + bucket: "answer", + }); + + expect(result.limited).toBe(true); + expect(client.rpc).toHaveBeenCalledWith( + "consume_api_subject_rate_limit", + expect.objectContaining({ p_subject_key: "anon:test-key", p_bucket: "answer" }), + ); +}); +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "anonymous answer requests by anonymous subject key" +``` + +Expected: FAIL because `consumeSubjectApiRateLimit` does not exist. + +- [ ] **Step 3: Add SQL migration for anonymous subjects** + +Create `supabase/migrations/YYYYMMDDHHMMSS_public_api_rate_limit_subjects.sql` with a timestamp later than existing migrations: + +```sql +set search_path = public, extensions, pg_temp; + +create table if not exists public.api_rate_limit_subjects ( + subject_key text not null, + bucket text not null, + window_start timestamptz not null default now(), + request_count integer not null default 0 check (request_count >= 0), + updated_at timestamptz not null default now(), + primary key (subject_key, bucket), + constraint api_rate_limit_subjects_subject_key_nonempty check (btrim(subject_key) <> ''), + constraint api_rate_limit_subjects_bucket_nonempty check (btrim(bucket) <> '') +); + +create index if not exists api_rate_limit_subjects_bucket_updated_idx + on public.api_rate_limit_subjects(bucket, updated_at desc); + +create or replace function public.consume_api_subject_rate_limit( + p_subject_key text, + p_bucket text, + p_limit integer, + p_window_seconds integer +) +returns table ( + limited boolean, + limit_value integer, + remaining integer, + retry_after_seconds integer, + reset_at timestamptz +) +language plpgsql +security definer +set search_path = public, extensions, pg_temp +as $$ +declare + v_now timestamptz := now(); + v_window_start timestamptz := v_now; + v_count integer; + v_reset_at timestamptz; +begin + if p_subject_key is null or btrim(p_subject_key) = '' then + raise exception 'subject_key is required'; + end if; + if p_bucket is null or btrim(p_bucket) = '' then + raise exception 'bucket is required'; + end if; + if p_limit < 1 then + raise exception 'limit must be positive'; + end if; + if p_window_seconds < 1 then + raise exception 'window must be positive'; + end if; + + loop + update public.api_rate_limit_subjects + set + window_start = case + when window_start + make_interval(secs => p_window_seconds) <= v_now then v_window_start + else window_start + end, + request_count = case + when window_start + make_interval(secs => p_window_seconds) <= v_now then 1 + else request_count + 1 + end, + updated_at = v_now + where subject_key = p_subject_key + and bucket = p_bucket + returning request_count, window_start + make_interval(secs => p_window_seconds) + into v_count, v_reset_at; + + exit when found; + + begin + insert into public.api_rate_limit_subjects(subject_key, bucket, window_start, request_count, updated_at) + values (p_subject_key, p_bucket, v_window_start, 1, v_now) + returning request_count, window_start + make_interval(secs => p_window_seconds) + into v_count, v_reset_at; + exit; + exception when unique_violation then + end; + end loop; + + return query + select + v_count > p_limit as limited, + p_limit as limit_value, + greatest(p_limit - v_count, 0) as remaining, + greatest(1, ceiling(extract(epoch from (v_reset_at - v_now)))::integer) as retry_after_seconds, + v_reset_at as reset_at; +end; +$$; + +revoke all privileges on table public.api_rate_limit_subjects from public, anon, authenticated; +grant select, insert, update, delete on table public.api_rate_limit_subjects to service_role; + +revoke execute on function public.consume_api_subject_rate_limit(text, text, integer, integer) from public, anon, authenticated; +grant execute on function public.consume_api_subject_rate_limit(text, text, integer, integer) to service_role; + +alter table public.api_rate_limit_subjects enable row level security; + +drop policy if exists "api rate limit subjects service role all" on public.api_rate_limit_subjects; +create policy "api rate limit subjects service role all" on public.api_rate_limit_subjects + for all to service_role + using (true) + with check (true); +``` + +- [ ] **Step 4: Mirror SQL in `supabase/schema.sql`** + +Add the table, index, function, grants, RLS enable, and service-role policy to `supabase/schema.sql` near the existing `api_rate_limits` definitions. + +- [ ] **Step 5: Implement subject limiter** + +In `src/lib/api-rate-limit.ts`, import the type: + +```ts +import type { RateLimitSubject } from "@/lib/public-api-access"; +``` + +Add below `consumeApiRateLimit`: + +```ts +export async function consumeSubjectApiRateLimit(args: { + supabase: SupabaseAdmin; + subject: RateLimitSubject; + bucket: ApiRateLimitBucket; + limit?: number; + windowSeconds?: number; + allowInMemoryFallbackOnUnavailable?: boolean; +}): Promise { + if (args.subject.kind === "owner") { + return consumeApiRateLimit({ + supabase: args.supabase, + ownerId: args.subject.ownerId, + bucket: args.bucket, + limit: args.limit, + windowSeconds: args.windowSeconds, + allowInMemoryFallbackOnUnavailable: args.allowInMemoryFallbackOnUnavailable, + }); + } + + const defaults = apiRateLimitDefaults[args.bucket]; + const limit = args.limit ?? defaults.limit; + const windowSeconds = args.windowSeconds ?? defaults.windowSeconds; + const { data, error } = await args.supabase.rpc("consume_api_subject_rate_limit", { + p_subject_key: args.subject.subjectKey, + p_bucket: args.bucket, + p_limit: limit, + p_window_seconds: windowSeconds, + }); + + if (error) { + if (args.allowInMemoryFallbackOnUnavailable) { + console.warn("Durable anonymous API rate limit check unavailable; using local in-memory fallback.", { + bucket: args.bucket, + code: error.code, + message: error.message, + }); + return consumeInMemoryApiRateLimit({ + ownerId: args.subject.subjectKey, + bucket: args.bucket, + limit, + windowSeconds, + }); + } + throw new ApiRateLimitUnavailableError(); + } + + const row = parseRateLimitRow(data); + if (!row || typeof row.limited !== "boolean") { + if (args.allowInMemoryFallbackOnUnavailable) { + return consumeInMemoryApiRateLimit({ + ownerId: args.subject.subjectKey, + bucket: args.bucket, + limit, + windowSeconds, + }); + } + throw new ApiRateLimitUnavailableError(); + } + + return { + limited: row.limited, + limit: Number(row.limit_value ?? limit), + remaining: Number(row.remaining ?? 0), + retryAfterSeconds: Math.max(1, Number(row.retry_after_seconds ?? windowSeconds)), + resetAt: String(row.reset_at ?? new Date(Date.now() + windowSeconds * 1000).toISOString()), + }; +} +``` + +- [ ] **Step 6: Run focused limiter test** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "anonymous answer requests by anonymous subject key" +``` + +Expected: PASS. + +--- + +### Task 3: Make Search and Answer Public While Rate Limited + +**Files:** + +- Modify: `src/app/api/search/route.ts:882-909` +- Modify: `src/app/api/answer/route.ts:64-108` +- Modify: `src/app/api/answer/stream/route.ts:220-236` +- Test: `tests/private-access-routes.test.ts:2688-2804` + +**Interfaces:** + +- Consumes: `publicAccessContext`, `consumeSubjectApiRateLimit`. +- Produces: anonymous `/api/search`, `/api/answer`, and `/api/answer/stream` success instead of 401. + +- [ ] **Step 1: Replace the old failing 401 test with anonymous live-search expectations** + +Replace `it("rejects unauthenticated search and answer requests"...)` in `tests/private-access-routes.test.ts:2688` with: + +```ts +it("allows anonymous search and answer requests with anonymous rate limits", async () => { + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [], + telemetry: { + search_cache_hit: false, + text_fast_path_latency_ms: 0, + embedding_skipped: true, + embedding_latency_ms: 0, + embedding_cache_hit: false, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, + retrieval_strategy: "text_fast_path", + }, + })); + const answerQuestionWithScope = vi.fn(async () => ({ + answer: "No evidence found.", + grounded: false, + confidence: "unsupported", + citations: [], + sources: [], + })); + const client = createSupabaseMock(); + client.rpc.mockImplementation(async (name: string) => + name === "consume_api_subject_rate_limit" || name === "match_documents_hybrid" + ? { data: name === "consume_api_subject_rate_limit" ? [rateLimitRow()] : [], error: null } + : ok([]), + ); + mockRuntime(client, { searchChunksWithTelemetry, answerQuestionWithScope }); + + const searchRoute = await import("../src/app/api/search/route"); + const answerRoute = await import("../src/app/api/answer/route"); + + const anonymousHeaders = { + "x-forwarded-for": "203.0.113.20", + "user-agent": "Vitest Browser", + }; + const searchResponse = await searchRoute.POST( + request("/api/search", { + method: "POST", + headers: anonymousHeaders, + body: JSON.stringify({ query: "monitoring", documentIds: [otherDocumentId] }), + }), + ); + const answerResponse = await answerRoute.POST( + request("/api/answer", { + method: "POST", + headers: anonymousHeaders, + body: JSON.stringify({ query: "monitoring", documentId: otherDocumentId }), + }), + ); + + expect(searchResponse.status).toBe(200); + expect(answerResponse.status).toBe(200); + expect(searchChunksWithTelemetry).toHaveBeenCalledWith(expect.objectContaining({ ownerId: undefined })); + expect(answerQuestionWithScope).toHaveBeenCalledWith( + expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }), + ); + expect(client.auth.getUser).not.toHaveBeenCalled(); + expect(client.rpc).toHaveBeenCalledWith( + "consume_api_subject_rate_limit", + expect.objectContaining({ p_subject_key: expect.stringMatching(/^anon:/), p_bucket: "search" }), + ); + expect(client.rpc).toHaveBeenCalledWith( + "consume_api_subject_rate_limit", + expect.objectContaining({ p_subject_key: expect.stringMatching(/^anon:/), p_bucket: "answer" }), + ); +}); +``` + +- [ ] **Step 2: Add a stream route anonymous test** + +Add below the anonymous search/answer test: + +```ts +it("allows anonymous streamed answers with anonymous rate limits", async () => { + const answerQuestionWithScope = vi.fn(async () => ({ + answer: "Streamed public answer.", + grounded: false, + confidence: "unsupported", + citations: [], + sources: [], + })); + const client = createSupabaseMock(); + client.rpc.mockImplementation(async (name: string) => + name === "consume_api_subject_rate_limit" ? { data: [rateLimitRow()], error: null } : ok([]), + ); + mockRuntime(client, { answerQuestionWithScope }); + const answerStreamRoute = await import("../src/app/api/answer/stream/route"); + + const response = await answerStreamRoute.POST( + request("/api/answer/stream", { + method: "POST", + headers: { "x-forwarded-for": "203.0.113.21", "user-agent": "Vitest Browser" }, + body: JSON.stringify({ query: "monitoring" }), + }), + ); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(ssePayload(body, "final")).toMatchObject({ answer: "Streamed public answer." }); + expect(answerQuestionWithScope).toHaveBeenCalledWith( + expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }), + ); + expect(client.rpc).toHaveBeenCalledWith( + "consume_api_subject_rate_limit", + expect.objectContaining({ p_subject_key: expect.stringMatching(/^anon:/), p_bucket: "answer" }), + ); +}); +``` + +- [ ] **Step 3: Run the focused tests and verify they fail** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "anonymous search and answer|anonymous streamed answers" +``` + +Expected: FAIL with old 401 behavior. + +- [ ] **Step 4: Update `/api/search`** + +In `src/app/api/search/route.ts`, replace the authenticated-only block at `882-897` with: + +```ts +supabase = createAdminClient(); +const access = await publicAccessContext(request, supabase); +ownerId = access.ownerId; + +const rateLimit = await consumeSubjectApiRateLimit({ + supabase, + subject: access.rateLimitSubject, + bucket: "search", + allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), +}); +if (rateLimit.limited) { + return rateLimitJsonResponse( + "Search is temporarily rate limited because too many requests were received. Retry shortly.", + rateLimit, + ); +} +``` + +Add imports: + +```ts +import { publicAccessContext } from "@/lib/public-api-access"; +import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +``` + +Remove `serverAuth.requireAuthenticatedUser` use from this route but keep `AuthenticationError` catch only if optional auth can throw for invalid tokens. + +- [ ] **Step 5: Update `/api/answer`** + +In `src/app/api/answer/route.ts`, replace `requireAuthenticatedUser` and owner-only rate limit with: + +```ts +const supabase = createAdminClient(); +const access = await publicAccessContext(request, supabase); + +const rateLimit = await consumeSubjectApiRateLimit({ + supabase, + subject: access.rateLimitSubject, + bucket: "answer", + allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), +}); +if (rateLimit.limited) { + return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); +} + +const scope = await resolveSearchScope({ + supabase, + ownerId: access.ownerId, + documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), + filters: body.filters, +}); +``` + +Then call answer generation with: + +```ts +const answer = await answerQuestionWithScope({ + query: body.query, + documentId: singleDocumentScope ? body.documentId : undefined, + documentIds: singleDocumentScope + ? undefined + : (scope.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined)), + ownerId: access.ownerId, + allowGlobalSearch: !access.ownerId, + queryMode: body.queryMode, + skipCache: body.skipCache, + signal: request.signal, +}); +``` + +- [ ] **Step 6: Update `/api/answer/stream`** + +In `src/app/api/answer/stream/route.ts`, replace `requireAuthenticatedUser` and owner-only rate limit with the same `publicAccessContext` and `consumeSubjectApiRateLimit` pattern. Return: + +```ts +return streamAnswer(body, access.ownerId, request.signal); +``` + +Inside `streamAnswer`, keep the existing `allowGlobalSearch: !ownerId` behavior. + +- [ ] **Step 7: Run focused tests** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "anonymous search and answer|anonymous streamed answers|rate limits authenticated answer" +``` + +Expected: PASS. + +--- + +### Task 4: Allow Anonymous Document Listing and Source Preview for Live Search Results + +**Files:** + +- Modify: `src/app/api/documents/route.ts:118-203` +- Modify after inspection: `src/app/api/documents/[id]/route.ts` +- Modify after inspection: `src/app/api/documents/[id]/search/route.ts` +- Modify after inspection: `src/app/api/documents/[id]/signed-url/route.ts` +- Modify after inspection: `src/app/api/images/[id]/signed-url/route.ts` +- Test: `tests/private-access-routes.test.ts` + +**Interfaces:** + +- Consumes: `publicAccessContext`. +- Produces: anonymous document browsing/source previews; authenticated users remain owner-scoped. + +- [ ] **Step 1: Write failing anonymous document list test** + +Add near document listing tests: + +```ts +it("lists documents anonymously for public browsing without an auth session", async () => { + const documents = [{ id: documentId, owner_id: userId, title: "Public guideline", status: "indexed" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/route"); + + const response = await GET(request("/api/documents?includeMeta=false")); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents); + expect(client.auth.getUser).not.toHaveBeenCalled(); + expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId }); +}); +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "lists documents anonymously" +``` + +Expected: FAIL with 401. + +- [ ] **Step 3: Implement anonymous document list** + +In `src/app/api/documents/route.ts`, replace: + +```ts +const user = await requireAuthenticatedUser(request, supabase); +let query = supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" }).eq("owner_id", user.id); +``` + +with: + +```ts +const access = await publicAccessContext(request, supabase); +let query = supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" }); +if (access.ownerId) query = query.eq("owner_id", access.ownerId); +query = query; +``` + +then keep the existing `.order(...).range(...)` chain after `query = query`. + +- [ ] **Step 4: Update source-preview routes using the same pattern** + +For each route that currently calls `requireAuthenticatedUser` and then filters by `owner_id`, change to: + +```ts +const access = await publicAccessContext(request, supabase); +let documentQuery = supabase.from("documents").select("...").eq("id", id); +if (access.ownerId) documentQuery = documentQuery.eq("owner_id", access.ownerId); +``` + +Apply this to GET/search/signed-url preview routes only. Do not apply it to delete, label mutation, reindex, or bulk mutation routes in this task. + +- [ ] **Step 5: Add route-specific tests for one source preview and one signed URL** + +Add tests for anonymous success on `documents/[id]` and signed URL routes. Expected assertions: + +```ts +expect(response.status).toBe(200); +expect(client.auth.getUser).not.toHaveBeenCalled(); +expect(documentSelect?.filters).toContainEqual({ column: "id", value: documentId }); +expect(documentSelect?.filters).not.toContainEqual({ column: "owner_id", value: userId }); +``` + +- [ ] **Step 6: Run focused document tests** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "documents anonymously|source preview|signed URL" +``` + +Expected: PASS. + +--- + +### Task 5: Decide and Implement Anonymous Upload Behavior + +**Files:** + +- Modify: `src/app/api/upload/route.ts:24-238` +- Modify: `src/components/clinical-dashboard/DocumentManagerPanel.tsx` +- Test: `tests/private-access-routes.test.ts`, `tests/api-validation-contract.test.ts` + +**Interfaces:** + +- Consumes: `publicWorkspaceOwnerId()`, `publicUploadsEnabled()`, `publicAccessContext`. +- Produces: either public upload to shared owner when configured, or no login prompt when not configured. + +- [ ] **Step 1: Write tests for the chosen upload contract** + +Use this contract: anonymous upload is allowed only when `NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED="true"` and `PUBLIC_WORKSPACE_OWNER_ID` is configured. Otherwise the app shows setup-required copy, not sign-in-required copy. + +Add tests: + +```ts +it("rejects anonymous upload with setup guidance when public uploads are not configured", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" })); + + const response = await POST(request("/api/upload", { method: "POST", body: formData })); + + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); + expect(client.auth.getUser).not.toHaveBeenCalled(); +}); + +it("uploads anonymous documents to the configured public workspace owner", async () => { + const publicOwnerId = "99999999-9999-4999-8999-999999999999"; + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null); + if (call.table === "documents" && call.operation === "insert") + return ok({ id: documentId, owner_id: publicOwnerId }); + if (call.table === "ingestion_jobs" && call.operation === "insert") return ok({ id: "job-1" }); + if (call.table === "audit_log" && call.operation === "insert") return ok([]); + return ok([]); + }); + mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId }); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" })); + + const response = await POST(request("/api/upload", { method: "POST", body: formData })); + + expect(response.status).toBe(200); + expect(client.auth.getUser).not.toHaveBeenCalled(); + expect( + client.calls.find((call) => call.table === "documents" && call.operation === "insert")?.insertPayload, + ).toMatchObject({ + owner_id: publicOwnerId, + }); +}); +``` + +- [ ] **Step 2: Run upload tests and verify they fail** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "anonymous upload|Public uploads" +``` + +Expected: FAIL with old auth requirement. + +- [ ] **Step 3: Implement owner resolution in upload route** + +In `src/app/api/upload/route.ts`, replace the required auth block with: + +```ts +const access = await publicAccessContext(request, adminSupabase); +const publicOwnerId = publicWorkspaceOwnerId(); +const uploadOwnerId = access.ownerId ?? (publicUploadsEnabled() ? publicOwnerId : null); +if (!uploadOwnerId) { + return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 }); +} +``` + +Then replace every `user.id` in upload ownership/storage/audit/naming code with `uploadOwnerId`. + +- [ ] **Step 4: Update upload UI copy** + +In `DocumentManagerPanel.tsx`, replace copy like: + +```ts +"Sign in before uploading private guideline files."; +``` + +with: + +```ts +"Uploads are unavailable until this public workspace is configured."; +``` + +- [ ] **Step 5: Run upload tests** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "anonymous upload|Public uploads" +``` + +Expected: PASS. + +--- + +### Task 6: Remove Forced Login UI and Fix “Search request was not authorized” UX + +**Files:** + +- Modify: `src/components/ClinicalDashboard.tsx:3652-3664`, `src/components/ClinicalDashboard.tsx:5341-5893` +- Modify: `src/components/clinical-dashboard/auth-panel.tsx` +- Modify: `src/components/clinical-dashboard/document-search-results.tsx` +- Modify: `src/components/DocumentViewer.tsx:1928-2256` +- Test: `tests/ui-smoke.spec.ts` or focused component route tests if present + +**Interfaces:** + +- Consumes: public API behavior from Tasks 3-5. +- Produces: no forced sign-in panel; search controls enabled when backend setup is ready; no 401 authorization banner for anonymous users. + +- [ ] **Step 1: Write or update a UI smoke test** + +Add/update Playwright assertion in `tests/ui-smoke.spec.ts`: + +```ts +test("anonymous user can see enabled live search without sign-in gate", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Sign in for private documents")).toHaveCount(0); + await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0); + await expect(page.getByTestId("global-search-input")).toBeEnabled(); +}); +``` + +- [ ] **Step 2: Run the UI test and verify it fails or exposes current issue** + +Run: + +```powershell +npm run ensure +npm run test:e2e:chromium -- tests/ui-smoke.spec.ts -g "anonymous user can see enabled live search" +``` + +Expected: FAIL if the sign-in gate or disabled search is still present. + +- [ ] **Step 3: Update dashboard readiness logic** + +In `src/components/ClinicalDashboard.tsx`, change: + +```ts +const canUsePrivateApis = + localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); +const canRunSearch = explicitDemoMode || (hasReadyPublicSearchSetup(setupChecks) && canUsePrivateApis); +``` + +to: + +```ts +const publicApiReady = localProjectReady && hasReadyPublicSearchSetup(setupChecks); +const canUsePrivateApis = publicApiReady; +const canRunSearch = explicitDemoMode || publicApiReady; +``` + +Keep `authStatus === "authenticated"` only for optional account-specific UI, not for search availability. + +- [ ] **Step 4: Hide the forced auth panel** + +Change: + +```ts +const showAuthPanel = !clientDemoMode && !canUsePrivateApis; +``` + +or its equivalent to: + +```ts +const showAuthPanel = false; +``` + +If an optional account panel should remain in settings, render `AuthPanel` only inside settings/account surfaces, not in the main dashboard flow. + +- [ ] **Step 5: Replace user-facing authUnavailable copy** + +Search for strings: + +```text +Sign in +not authorized +Authentication required +Sign in or enable local no-auth mode +``` + +Replace public-flow copy with: + +```text +The public workspace is not ready yet. Check setup status and try again. +``` + +For actual 429 errors, show: + +```text +Too many requests. Please wait and retry shortly. +``` + +- [ ] **Step 6: Update DocumentViewer public readiness** + +In `src/components/DocumentViewer.tsx`, change public source preview readiness from auth-dependent to setup-dependent. Replace logic equivalent to: + +```ts +const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); +``` + +with: + +```ts +const canUsePrivateApis = localProjectReady && (clientDemoMode || setupReadyForPublicApis); +``` + +Use the same existing setup signal used by the dashboard if available; if not available, derive it from successful setup-status/document fetch state in that component. + +- [ ] **Step 7: Run UI smoke test** + +Run: + +```powershell +npm run ensure +npm run test:e2e:chromium -- tests/ui-smoke.spec.ts -g "anonymous user can see enabled live search" +``` + +Expected: PASS. + +--- + +### Task 7: Verify Authenticated Behavior Still Works and Destructive Routes Stay Protected + +**Files:** + +- Modify tests only unless failures reveal route regressions: + - `tests/private-access-routes.test.ts` + - `tests/api-validation-contract.test.ts` + +**Interfaces:** + +- Consumes: public access route changes. +- Produces: confidence that anonymous access did not expose destructive actions. + +- [ ] **Step 1: Add protected mutation regression tests** + +Add tests proving anonymous delete/reindex/label mutation still fails: + +```ts +it("keeps destructive document mutations protected from anonymous callers", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const documentRoute = await import("../src/app/api/documents/[id]/route"); + const reindexRoute = await import("../src/app/api/documents/[id]/reindex/route"); + + const deleteResponse = await documentRoute.DELETE(request(`/api/documents/${documentId}`, { method: "DELETE" }), { + params: Promise.resolve({ id: documentId }), + }); + const reindexResponse = await reindexRoute.POST(request(`/api/documents/${documentId}/reindex`, { method: "POST" }), { + params: Promise.resolve({ id: documentId }), + }); + + expect(deleteResponse.status).toBe(401); + expect(reindexResponse.status).toBe(401); + expect(client.calls.some((call) => call.operation === "delete" || call.operation === "update")).toBe(false); +}); +``` + +- [ ] **Step 2: Run protected mutation tests** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts -t "destructive document mutations" +``` + +Expected: PASS. + +- [ ] **Step 3: Run broader private/public route tests** + +Run: + +```powershell +npm run test -- tests/private-access-routes.test.ts +npm run test -- tests/api-validation-contract.test.ts +``` + +Expected: PASS. + +--- + +### Task 8: Full Verification and Production Readiness Checks + +**Files:** + +- No source changes unless verification fails. + +**Interfaces:** + +- Consumes: all prior tasks. +- Produces: verified anonymous live-search behavior. + +- [ ] **Step 1: Run cheap verification** + +Run: + +```powershell +npm run verify:cheap +``` + +Expected: PASS. + +- [ ] **Step 2: Run UI verification because this changes frontend/auth UX** + +Run: + +```powershell +npm run ensure +npm run verify:ui +``` + +Expected: PASS. If browser setup is unavailable, record the exact failure and run the focused Playwright smoke test from Task 6 instead. + +- [ ] **Step 3: Run production readiness because this changes auth/public access** + +Run: + +```powershell +npm run check:production-readiness +``` + +Expected: PASS or known environment-only warnings. Fix any new failures caused by public access env checks. + +- [ ] **Step 4: Manual smoke path** + +Use the URL printed by `npm run ensure` and verify: + +```text +1. Open the app in a fresh/incognito browser. +2. Confirm no "Sign in for private documents" panel appears. +3. Type a clinical query into the composer. +4. Submit the query. +5. Confirm no "Search request was not authorized by the server" message appears. +6. Confirm search/answer either returns live source-backed content or a non-auth setup/rate-limit message. +7. Open a source preview from results. +8. Confirm source preview does not ask for login. +9. Repeat rapid submissions until a 429 appears. +10. Confirm 429 copy tells the user to retry shortly, not sign in. +``` + +Expected: anonymous users can run live searches; excessive use is rate-limited. + +--- + +## Self-Review + +**Spec coverage:** + +- Forced login removal: Task 6. +- “Search request was not authorized” fix: Tasks 3 and 6. +- Live anonymous search: Task 3. +- Source preview without login: Task 4. +- Abuse control without captcha: Task 2 plus Task 3 route use. +- Upload behavior without login: Task 5, with shared public owner guard. +- Destructive action safety: Task 7. +- Verification: Task 8. + +**Placeholder scan:** No TODO/TBD placeholders remain. The SQL migration filename needs a real timestamp at execution time because migration names must be unique in the executing branch. + +**Type consistency:** `RateLimitSubject`, `publicAccessContext`, and `consumeSubjectApiRateLimit` signatures are used consistently across tasks. + +**Non-commit note:** This plan intentionally excludes commit steps because the user has not requested a commit. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 6ffa12b24..2c5afe126 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -206,6 +206,74 @@ function renderModeRoutes() { ); } +type ModePageIndexRow = { + mode: string; + home: string; + search: string; + detail: string; +}; + +function renderRouteTable(rows: ModePageIndexRow[]) { + return [ + "| Mode | Home page | Search/results page | Information/detail pages |", + "| --- | --- | --- | --- |", + ...rows.map((row) => `| ${row.mode} | \`${row.home}\` | \`${row.search}\` | ${row.detail} |`), + ]; +} + +function renderModePageIndex() { + return renderRouteTable([ + { + mode: "Answer", + home: appModeHomeHref("answer"), + search: appModeHomeHref("answer", { query: "example question", focus: true, run: true }), + detail: "Answer, citations, evidence, and source panels render inside the root dashboard shell.", + }, + { + mode: "Documents", + home: appModeHomeHref("documents"), + search: appModeHomeHref("documents", { query: "lithium monitoring", focus: true, run: true }), + detail: "`/documents/[id]` document viewer and in-document search.", + }, + { + mode: "Services", + home: appModeHomeHref("services"), + search: appModeHomeHref("services", { query: "13YARN", focus: true, run: true }), + detail: "`/services/[slug]` service record pages.", + }, + { + mode: "Forms", + home: appModeHomeHref("forms"), + search: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), + detail: "`/forms/[slug]` form record pages.", + }, + { + mode: "Favourites", + home: appModeHomeHref("favourites"), + search: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), + detail: "Saved set and saved item detail render inside the favourites page surface.", + }, + { + mode: "Differentials", + home: appModeHomeHref("differentials"), + search: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), + detail: "`/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`.", + }, + { + mode: "Medication", + home: appModeHomeHref("prescribing"), + search: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), + detail: "`/medications/[slug]`; `/medications` redirects to medication mode.", + }, + { + mode: "Tools", + home: appModeHomeHref("tools"), + search: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), + detail: "`/applications` launcher and tool detail panels inside tools mode.", + }, + ]); +} + function section(title: string, lines: string[]) { return [`## ${title}`, "", ...lines, ""]; } @@ -235,6 +303,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { productRoutes.map((route) => routeLine(route, routeDescriptions)), ), ...section("Mode/query routes", renderModeRoutes()), + ...section("Mode page index", renderModePageIndex()), ...section("Registry-backed routes", [ bullet( "/services/[slug]", diff --git a/src/app/favourites/legacy/page.tsx b/src/app/favourites/legacy/page.tsx new file mode 100644 index 000000000..e0095f8b6 --- /dev/null +++ b/src/app/favourites/legacy/page.tsx @@ -0,0 +1,18 @@ +import { FavouritesHomePage } from "@/components/clinical-dashboard/favourites-home-page"; + +type LegacyFavouritesPageProps = { + searchParams?: Promise<{ + q?: string | string[]; + }>; +}; + +function firstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function LegacyFavouritesPage({ searchParams }: LegacyFavouritesPageProps) { + const params = searchParams ? await searchParams : {}; + const query = firstSearchParam(params.q)?.trim() ?? ""; + + return ; +} diff --git a/src/app/favourites/page.tsx b/src/app/favourites/page.tsx index 8dd8c0675..d8317ff84 100644 --- a/src/app/favourites/page.tsx +++ b/src/app/favourites/page.tsx @@ -1,18 +1,5 @@ -import { FavouritesHomePage } from "@/components/clinical-dashboard/favourites-home-page"; +import { FavouritesCommandLibraryPage } from "@/components/clinical-dashboard/favourites-command-library-page"; -type FavouritesPageProps = { - searchParams?: Promise<{ - q?: string | string[]; - }>; -}; - -function firstSearchParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0] : value; -} - -export default async function FavouritesPage({ searchParams }: FavouritesPageProps) { - const params = searchParams ? await searchParams : {}; - const query = firstSearchParam(params.q)?.trim() ?? ""; - - return ; +export default function FavouritesPage() { + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index 09512995c..b6fa79258 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -452,7 +452,7 @@ summary::-webkit-details-marker { * here so the whole class stays in one layer. * * The COMPOSER chrome further below (answer-footer-search-*, - * desktop-home-search-*, document-mobile-search-pill) stays INTENTIONALLY + * document-mobile-search-pill) stays INTENTIONALLY * UNLAYERED: its call sites stack utilities from shared ui-primitives * constants that rely on the class winning, and PR #171's frosted rework * left ~40 value conflicts there. Reconciling those is tracked in @@ -525,6 +525,21 @@ summary::-webkit-details-marker { } } +.mode-home-composer-slot { + width: min(100%, clamp(19rem, 90vw, 52rem)); + max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right)); + margin-inline: auto; +} + +.universal-home-search-edge { + width: 100%; +} + +.universal-top-search-edge { + width: min(100%, clamp(19rem, 88vw, 52rem)); + max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right)); +} + .floating-composer-edge { left: max(0.75rem, var(--safe-area-left)); right: max(0.75rem, var(--safe-area-right)); @@ -532,7 +547,7 @@ summary::-webkit-details-marker { } .answer-footer-search-edge { - bottom: max(1.45rem, calc(var(--safe-area-bottom) + 1rem)); + bottom: max(0.45rem, calc(var(--safe-area-bottom) + 0.35rem)); } .answer-footer-search-pill { @@ -553,6 +568,26 @@ summary::-webkit-details-marker { transform 180ms ease; } +.answer-footer-search-backdrop { + pointer-events: none; + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 0; + height: max(6.5rem, calc(var(--safe-area-bottom) + 5.25rem)); + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--background) 10%, transparent) 26%, + color-mix(in srgb, var(--background) 32%, transparent) 62%, + color-mix(in srgb, var(--background) 46%, transparent) 100% + ); + backdrop-filter: blur(14px) saturate(130%); + -webkit-backdrop-filter: blur(14px) saturate(130%); + mask-image: linear-gradient(180deg, transparent 0%, black 32%, black 100%); + -webkit-mask-image: linear-gradient(180deg, transparent 0%, black 32%, black 100%); +} + .answer-footer-search-pill:hover { border-color: var(--border-strong); box-shadow: @@ -569,6 +604,297 @@ summary::-webkit-details-marker { 0 20px 48px rgb(16 24 40 / 12%); } +.answer-footer-search-pill-open { + border-color: color-mix(in srgb, var(--clinical-accent) 46%, var(--border-strong)); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 10%, transparent), + 0 8px 20px rgb(16 24 40 / 8%), + 0 22px 52px rgb(16 24 40 / 14%); +} + +.answer-footer-search-pill-open[data-menu-placement="up"] { + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; +} + +.answer-footer-search-pill-open[data-menu-placement="down"] { + border-bottom-left-radius: 1rem; + border-bottom-right-radius: 1rem; +} + +.mode-action-surface { + --mode-action-max-height: min(72dvh, 34rem); + --mode-action-body-max-height: min(54dvh, 24rem); + transform-origin: center bottom; +} + +.mode-action-surface[data-placement="down"] { + transform-origin: center top; +} + +.mode-action-panel { + max-height: var(--mode-action-max-height); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); +} + +.mode-action-surface[data-placement="up"] .mode-action-panel { + border-bottom-left-radius: 1rem; + border-bottom-right-radius: 1rem; +} + +.mode-action-surface[data-placement="down"] .mode-action-panel { + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; +} + +.mode-action-header { + position: relative; + z-index: 1; + display: grid; + min-height: 4.9rem; + grid-template-areas: "selector summary close"; + grid-template-columns: minmax(13.5rem, 0.42fr) minmax(0, 1fr) 3.25rem; + align-items: center; + gap: 0.75rem; + padding: 0.72rem 0.78rem; + color: #fff; + background: + radial-gradient(circle at 18% 0%, rgb(255 255 255 / 18%), transparent 34%), + linear-gradient( + 135deg, + color-mix(in srgb, var(--clinical-accent) 88%, #073d4a 12%) 0%, + color-mix(in srgb, var(--primary-700) 86%, #ffffff 14%) 100% + ); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 18%), + inset 0 -1px 0 rgb(255 255 255 / 10%); +} + +.mode-action-selector-shell { + position: relative; + grid-area: selector; + min-width: 0; +} + +.mode-action-mode-button { + display: grid; + min-height: 3.15rem; + width: 100%; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + border: 1px solid rgb(255 255 255 / 30%); + border-radius: 999px; + background: rgb(255 255 255 / 10%); + padding: 0.42rem 0.72rem; + color: #fff; + font-size: 1rem; + font-weight: 850; + line-height: 1; + text-align: left; + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 18%), + 0 8px 22px rgb(0 35 44 / 14%); + transition: + background-color 160ms ease, + border-color 160ms ease; +} + +.mode-action-mode-button:hover { + border-color: rgb(255 255 255 / 42%); + background: rgb(255 255 255 / 14%); +} + +.mode-action-mode-button:disabled { + cursor: default; +} + +.mode-action-mode-button:focus-visible, +.mode-action-close:focus-visible, +.mode-action-mode-option:focus-visible { + outline: 2px solid color-mix(in srgb, #fff 70%, var(--clinical-accent)); + outline-offset: 2px; +} + +.mode-action-mode-icon { + display: grid; + height: 2.1rem; + width: 2.1rem; + flex: 0 0 auto; + place-items: center; + border: 1px solid rgb(255 255 255 / 32%); + border-radius: 0.5rem; + background: rgb(255 255 255 / 12%); + color: #fff; +} + +.mode-action-header-summary { + grid-area: summary; + display: flex; + min-width: 0; + align-items: center; + gap: 1rem; + color: rgb(255 255 255 / 92%); + font-size: 0.98rem; + font-weight: 720; + line-height: 1.2; +} + +.mode-action-header-divider { + display: block; + height: 2.15rem; + width: 1px; + flex: 0 0 auto; + background: rgb(255 255 255 / 28%); +} + +.mode-action-close { + grid-area: close; + display: grid; + height: 2.7rem; + width: 2.7rem; + place-items: center; + justify-self: center; + border: 1px solid rgb(255 255 255 / 36%); + border-radius: 999px; + background: rgb(255 255 255 / 10%); + color: #fff; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 16%); + transition: + background-color 160ms ease, + border-color 160ms ease; +} + +.mode-action-close:hover { + border-color: rgb(255 255 255 / 48%); + background: rgb(255 255 255 / 16%); +} + +.mode-action-body { + max-height: var(--mode-action-body-max-height); + overflow-y: auto; + overscroll-behavior: contain; +} + +.mode-action-mode-menu { + position: absolute; + top: calc(100% + 0.45rem); + left: 0; + z-index: 20; + width: min(22rem, calc(100vw - 2rem)); + max-height: min(19rem, var(--mode-action-body-max-height)); + overflow-y: auto; + border: 1px solid var(--border-lux); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--surface-lux) 96%, transparent); + padding: 0.4rem; + color: var(--text); + box-shadow: 0 18px 48px rgb(15 37 48 / 18%); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); +} + +.mode-action-mode-option { + display: grid; + min-height: 3.2rem; + width: 100%; + grid-template-columns: 2.1rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + border: 1px solid transparent; + border-radius: 0.5rem; + padding: 0.45rem 0.55rem; + text-align: left; + color: var(--text-muted); + transition: + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease; +} + +.mode-action-mode-option:hover { + border-color: var(--border); + background: var(--surface-subtle); + color: var(--text); +} + +.mode-action-mode-option-active { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); + color: var(--text-heading); +} + +.mode-action-mode-option-icon { + display: grid; + height: 2rem; + width: 2rem; + place-items: center; + border: 1px solid var(--clinical-accent-border); + border-radius: 0.5rem; + background: color-mix(in srgb, var(--clinical-accent-soft) 72%, var(--surface-lux)); + color: var(--clinical-accent); +} + +@media (max-width: 430px) { + .mode-action-header { + min-height: 5.7rem; + grid-template-areas: + "selector close" + "summary summary"; + grid-template-columns: minmax(0, 1fr) 2.85rem; + gap: 0.5rem 0.65rem; + padding: 0.62rem; + } + + .mode-action-mode-button { + min-height: 2.85rem; + gap: 0.55rem; + padding-inline: 0.58rem; + font-size: 0.95rem; + } + + .mode-action-mode-icon { + height: 1.95rem; + width: 1.95rem; + } + + .mode-action-header-summary { + gap: 0; + padding-inline: 0.18rem; + font-size: 0.83rem; + white-space: normal; + } + + .mode-action-header-summary .truncate { + white-space: normal; + } + + .mode-action-header-divider { + display: none; + } + + .mode-action-close { + height: 2.55rem; + width: 2.55rem; + } + + .mode-action-mode-menu { + width: min(20rem, calc(100vw - 1.5rem)); + } +} + +@media (prefers-reduced-motion: no-preference) { + .mode-action-surface[data-placement="up"] { + animation: mode-action-fold-up 170ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + + .mode-action-surface[data-placement="down"] { + animation: mode-action-fold-down 170ms cubic-bezier(0.22, 1, 0.36, 1) both; + } +} + .answer-footer-search-action { height: 2.75rem; width: 2.75rem; @@ -668,7 +994,7 @@ summary::-webkit-details-marker { .dashboard-composer-edge.answer-footer-search-edge { left: 50%; right: auto; - width: min(calc(100vw - 16px - var(--safe-area-left) - var(--safe-area-right)), 400px); + width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); transform: translateX(-50%); } @@ -682,51 +1008,23 @@ summary::-webkit-details-marker { bottom: max(1.45rem, calc(var(--safe-area-bottom) + 1rem)); } -.desktop-home-search-pill { - min-height: 3.45rem; - gap: 0.375rem; - border-color: color-mix(in srgb, var(--border-strong) 80%, transparent); - background: color-mix(in srgb, var(--surface) 96%, transparent); - padding-inline: 0.5rem; - box-shadow: - 0 1px 2px rgb(16 24 40 / 4%), - 0 6px 16px rgb(16 24 40 / 6%), - 0 18px 40px rgb(16 24 40 / 9%); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - transition: - border-color 180ms ease, - box-shadow 180ms ease; -} - -.desktop-home-search-pill:hover { - border-color: var(--border-strong); - box-shadow: - 0 1px 2px rgb(16 24 40 / 5%), - 0 8px 20px rgb(16 24 40 / 8%), - 0 22px 48px rgb(16 24 40 / 11%); -} - -.desktop-home-search-pill:focus-within { - border-color: var(--clinical-accent); - box-shadow: - 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 16%, transparent), - 0 8px 20px rgb(16 24 40 / 8%), - 0 18px 40px rgb(16 24 40 / 9%); -} +@media (min-width: 640px) { + .mode-home-composer-slot { + width: min(100%, clamp(28rem, 74vw, 54rem)); + max-width: calc(100vw - 3rem - var(--safe-area-left) - var(--safe-area-right)); + } -.desktop-home-search-input { - font-size: 1rem; - font-weight: 560; -} + .universal-top-search-edge { + width: min(100%, clamp(28rem, 74vw, 54rem)); + max-width: calc(100vw - 3rem - var(--safe-area-left) - var(--safe-area-right)); + } -@media (min-width: 640px) { .floating-composer-edge { bottom: max(1rem, var(--safe-area-bottom)); } .answer-footer-search-edge { - bottom: max(1.25rem, calc(var(--safe-area-bottom) + 0.75rem)); + bottom: max(0.75rem, calc(var(--safe-area-bottom) + 0.5rem)); } .dashboard-composer-edge.answer-footer-search-edge { @@ -775,6 +1073,10 @@ summary::-webkit-details-marker { } @media (max-width: 639px) { + .dashboard-composer-edge.answer-footer-search-edge { + width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); + } + .document-mobile-search-edge { left: max(0.375rem, var(--safe-area-left)); right: max(0.375rem, var(--safe-area-right)); @@ -784,8 +1086,8 @@ summary::-webkit-details-marker { .document-mobile-search-edge.answer-footer-search-edge { left: 50%; right: auto; - bottom: max(0.5rem, calc(var(--safe-area-bottom) + 0.375rem)); - width: min(calc(100vw - 16px - var(--safe-area-left) - var(--safe-area-right)), 400px); + bottom: max(0.45rem, calc(var(--safe-area-bottom) + 0.35rem)); + width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); transform: translateX(-50%); } @@ -801,6 +1103,14 @@ summary::-webkit-details-marker { } @media (min-width: 1024px) { + .mode-home-composer-slot { + width: min(100%, clamp(36rem, 56vw, 56rem)); + } + + .universal-top-search-edge { + width: min(100%, clamp(36rem, 56vw, 56rem)); + } + .dashboard-composer-edge { left: calc(var(--clinical-sidebar-width, 20rem) + 2rem); right: max(2rem, var(--safe-area-right)); @@ -892,6 +1202,28 @@ summary::-webkit-details-marker { } } +@keyframes mode-action-fold-up { + from { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes mode-action-fold-down { + from { + opacity: 0; + transform: translateY(-8px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + @keyframes shimmer { from { background-position: -150% 0; @@ -905,8 +1237,8 @@ summary::-webkit-details-marker { * Helper classes with no same-property utility conflicts at any call site * live in @layer components so Tailwind utilities can override them. * Audited 2026-07-02: the chrome classes above (edge-glass-header, - * universal-header-*, answer-footer-search-*, *-composer-edge, - * desktop-home-search-*) DO conflict with call-site utilities and stay + * universal-header-*, answer-footer-search-*, *-composer-edge) DO + * conflict with call-site utilities and stay * unlayered deliberately — layering them changes rendered pixels. When * adding a utility to an element carrying one of those classes, check the * class body first; the class wins. diff --git a/src/app/mockups/document-search-command/page.tsx b/src/app/mockups/document-search-command/page.tsx index 1d6af0207..46503a4e8 100644 --- a/src/app/mockups/document-search-command/page.tsx +++ b/src/app/mockups/document-search-command/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; -import { DocumentSearchMockupPage } from "@/components/document-search-mockups"; +import { MasterDocumentSearch } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { title: "Document Search Command Mockup - Clinical KB", @@ -8,5 +8,5 @@ export const metadata: Metadata = { }; export default function DocumentSearchCommandMockupRoute() { - return ; + return ; } diff --git a/src/app/mockups/document-search/page.tsx b/src/app/mockups/document-search/page.tsx index 24e9f9d2b..ccc03a193 100644 --- a/src/app/mockups/document-search/page.tsx +++ b/src/app/mockups/document-search/page.tsx @@ -1,157 +1,17 @@ -import Image from "next/image"; -import Link from "next/link"; import type { Metadata } from "next"; -import { ArrowRight, FileText, Search, ShieldCheck, Sparkles } from "lucide-react"; +import { Suspense } from "react"; -import { cn } from "@/components/ui-primitives"; +import { MasterDocumentIndex } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { title: "Document Search Mockups - Clinical KB", - description: "Three runnable document-search UX concepts for Clinical KB document mode.", + description: "Master runnable document-search UX flow for Clinical KB document mode.", }; -const concepts = [ - { - href: "/mockups/document-search-command?mode=documents", - eyebrow: "Production candidate", - title: "Command center", - body: "Compact search, sort, result rows, and an active source preview for fast document lookup.", - image: "/mockups/document-search/source-stack.png", - alt: "Synthetic layered document stack with highlighted abstract source regions.", - icon: Search, - priorities: ["Fast scan", "Sort clarity", "Pinned preview"], - }, - { - href: "/mockups/document-search-evidence-lens?mode=documents", - eyebrow: "Evidence lens", - title: "Source proof in view", - body: "A split workbench that keeps the selected page, table, image, and ranking explanation together.", - image: "/mockups/document-search/evidence-preview.png", - alt: "Synthetic source page connected to abstract table, image, and warning evidence panels.", - icon: ShieldCheck, - priorities: ["Preview first", "Why this result", "Exact evidence"], - }, - { - href: "/mockups/document-search-triage-board?mode=documents", - eyebrow: "Discovery board", - title: "Library triage", - body: "A document-mode home for recent sources, source health, smart facets, and status lanes.", - image: "/mockups/document-search/triage-map.png", - alt: "Synthetic document triage board with abstract grouped source cards and status lanes.", - icon: Sparkles, - priorities: ["Recent work", "Source health", "Facet discovery"], - }, -] as const; - -const focusRing = - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; - -function Pill({ children, active = false }: { children: string; active?: boolean }) { - return ( - - {children} - - ); -} - export default function DocumentSearchMockupsIndexRoute() { return ( -
-
-
-
-
-
- - -

- Document mode UX -

-
-

- Three runnable document search directions -

-

- This page is the review board. Each direction below opens as its own full runnable mockup inside the - shared Clinical KB header and document-mode bottom composer. -

-
-
-

- Open a direction -

-
- Command - Evidence lens - Triage board -
-
-
-
- -
- {concepts.map((concept, index) => { - const Icon = concept.icon; - return ( - -
- {concept.alt} -
-
-
-
- - -

- {concept.eyebrow} -

-
-

- {concept.title} -

-

{concept.body}

-
- {concept.priorities.map((priority, priorityIndex) => ( - - {priority} - - ))} -
-
- - Open full mockup - - -
- - ); - })} -
-
-
+ + + ); } diff --git a/src/app/mockups/document-search/source-overlays/page.tsx b/src/app/mockups/document-search/source-overlays/page.tsx new file mode 100644 index 000000000..c414d5f96 --- /dev/null +++ b/src/app/mockups/document-search/source-overlays/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { SourceOverlayRedesignMockups } from "@/components/source-overlay-redesign-mockups"; + +export const metadata: Metadata = { + title: "Source Overlay Redesign Mockups - Clinical KB", + description: "Document scope and source library overlay redesign mockups for desktop and phone.", +}; + +export default function SourceOverlayRedesignMockupsRoute() { + return ; +} diff --git a/src/app/mockups/document-search/source/evidence/page.tsx b/src/app/mockups/document-search/source/evidence/page.tsx new file mode 100644 index 000000000..6d291518d --- /dev/null +++ b/src/app/mockups/document-search/source/evidence/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { MasterEvidenceDetail } from "@/components/master-document-flow-mockups"; + +export const metadata: Metadata = { + title: "Evidence Detail Mockup - Clinical KB", + description: "Functional evidence object mockup for tables, quotes, images, and source page context.", +}; + +export default function DocumentSearchEvidenceDetailRoute() { + return ( + + + + ); +} diff --git a/src/app/mockups/document-search/source/page.tsx b/src/app/mockups/document-search/source/page.tsx index 3bfaaa123..5143ab6b1 100644 --- a/src/app/mockups/document-search/source/page.tsx +++ b/src/app/mockups/document-search/source/page.tsx @@ -1,17 +1,17 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import { DocumentSearchLiveOpener } from "@/components/document-search-live-opener"; +import { MasterDocumentReader } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { - title: "Open Highlighted Document - Clinical KB", - description: "Resolves a document-search mockup result to the live document viewer with a selected source chunk.", + title: "Document Reader Mockup - Clinical KB", + description: "Functional document reader mockup with bundled PDF content, highlights, and evidence inspector.", }; export default function HighlightedDocumentSearchSourceRoute() { return ( - + ); } diff --git a/src/app/mockups/favourites-command-console/page.tsx b/src/app/mockups/favourites-command-console/page.tsx new file mode 100644 index 000000000..bd324500d --- /dev/null +++ b/src/app/mockups/favourites-command-console/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesLibraryRedesignPage } from "@/components/favourites-page-mockups/favourites-library-redesign-page"; + +export const metadata: Metadata = { + title: "Favourites Command Console Mockup - Clinical KB", + description: "Library-first favourites mockup with resume-next command workflow.", +}; + +export default function FavouritesCommandConsoleMockupRoute() { + return ; +} diff --git a/src/app/mockups/favourites-review-console/page.tsx b/src/app/mockups/favourites-review-console/page.tsx new file mode 100644 index 000000000..a0aa0b166 --- /dev/null +++ b/src/app/mockups/favourites-review-console/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesLibraryRedesignPage } from "@/components/favourites-page-mockups/favourites-library-redesign-page"; + +export const metadata: Metadata = { + title: "Favourites Review Console Mockup - Clinical KB", + description: "Library-first favourites mockup with stronger review and provenance workflow.", +}; + +export default function FavouritesReviewConsoleMockupRoute() { + return ; +} diff --git a/src/app/mockups/favourites-set-navigator/page.tsx b/src/app/mockups/favourites-set-navigator/page.tsx new file mode 100644 index 000000000..b6da91806 --- /dev/null +++ b/src/app/mockups/favourites-set-navigator/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesLibraryRedesignPage } from "@/components/favourites-page-mockups/favourites-library-redesign-page"; + +export const metadata: Metadata = { + title: "Favourites Set Navigator Mockup - Clinical KB", + description: "Library-first favourites mockup with workflow-set navigation.", +}; + +export default function FavouritesSetNavigatorMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 8a155b33b..2ebc5637c 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -9,11 +9,26 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const pathname = usePathname(); const isToolsPageMockup = pathname.startsWith("/mockups/tools-"); const isFavouritesPageMockup = pathname.startsWith("/mockups/favourites-"); + const isDocumentSearchMockup = pathname.startsWith("/mockups/document-search"); + const isSourceOverlayRedesignMockup = pathname === "/mockups/document-search/source-overlays"; + const isStandaloneDocumentFlow = + pathname === "/mockups/document-search" || pathname.startsWith("/mockups/document-search/source"); + const documentFlowOwnsMobileChrome = pathname.startsWith("/mockups/document-search/source"); return ( {children} diff --git a/src/app/mockups/tools-split-clinical-brief/page.tsx b/src/app/mockups/tools-split-clinical-brief/page.tsx new file mode 100644 index 000000000..ea580c5ae --- /dev/null +++ b/src/app/mockups/tools-split-clinical-brief/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsSplitPaneRefinedMockup } from "@/components/tools-page-mockups/split-pane-refined-mockups"; + +export const metadata: Metadata = { + title: "Tools Split Clinical Brief Mockup - Clinical KB", + description: "Refined split-pane Tools mockup with clinical brief and mobile popup.", +}; + +export default function ToolsSplitClinicalBriefMockupRoute() { + return ; +} diff --git a/src/app/mockups/tools-split-compact-sheet/page.tsx b/src/app/mockups/tools-split-compact-sheet/page.tsx new file mode 100644 index 000000000..013a78603 --- /dev/null +++ b/src/app/mockups/tools-split-compact-sheet/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsSplitPaneRefinedMockup } from "@/components/tools-page-mockups/split-pane-refined-mockups"; + +export const metadata: Metadata = { + title: "Tools Split Compact Sheet Mockup - Clinical KB", + description: "Refined split-pane Tools mockup with compact mobile action sheet.", +}; + +export default function ToolsSplitCompactSheetMockupRoute() { + return ; +} diff --git a/src/app/mockups/tools-split-safety-deck/page.tsx b/src/app/mockups/tools-split-safety-deck/page.tsx new file mode 100644 index 000000000..6bb3c3846 --- /dev/null +++ b/src/app/mockups/tools-split-safety-deck/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsSplitPaneRefinedMockup } from "@/components/tools-page-mockups/split-pane-refined-mockups"; + +export const metadata: Metadata = { + title: "Tools Split Safety Deck Mockup - Clinical KB", + description: "Refined split-pane Tools mockup with safety-focused clinical organisation.", +}; + +export default function ToolsSplitSafetyDeckMockupRoute() { + return ; +} diff --git a/src/app/services/layout.tsx b/src/app/services/layout.tsx index 0f0fa36cb..462b9148f 100644 --- a/src/app/services/layout.tsx +++ b/src/app/services/layout.tsx @@ -4,7 +4,7 @@ import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search export default function ServicesLayout({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 8e2915e08..bc4fbff80 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -1,5 +1,30 @@ +import { Suspense } from "react"; + import { ServicesHomePage } from "@/components/services/services-home-page"; +import { ServicesNavigatorPage } from "@/components/services/services-navigator-page"; + +type ServicesSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; + +function readFirstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function ServicesIndexRoute({ searchParams }: { searchParams: ServicesSearchParams }) { + const resolvedSearchParams = await searchParams; + const query = ( + readFirstSearchParam(resolvedSearchParams.q) ?? + readFirstSearchParam(resolvedSearchParams.query) ?? + "" + ).trim(); + const hasSubmittedSearch = resolvedSearchParams.run === "1" && query.length > 0; + + if (!hasSubmittedSearch) { + return ; + } -export default function ServicesIndexRoute() { - return ; + return ( + }> + + + ); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index c0e07a2ba..49ba42517 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,42 +1,115 @@ "use client"; +import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, + Bell, BookOpen, + CheckCircle2, + ChevronDown, + ChevronRight, + CircleUserRound, + Clock3, + ClipboardCheck, + Copy, + ExternalLink, + FileImage, FileText, + FolderOpen, + Globe2, + HelpCircle, Heart, + Keyboard, + Layers, ListChecks, Loader2, + LogOut, + Mail, + LockKeyhole, + Palette, + PanelTop, + Plus, + Quote, RefreshCw, Search, + Settings as SettingsIcon, ShieldAlert, + ShieldCheck, + SlidersHorizontal, + Sparkles, + Stethoscope, + Tag, UploadCloud, + UserRound, WifiOff, Wrench, X, } from "lucide-react"; -import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { + type CSSProperties, + type FormEvent, + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { AccessibleTable } from "@/components/AccessibleTable"; +import { + DocumentOrganizationBadges, + documentDisplayTitle, + documentOrganizationProfile, +} from "@/components/DocumentOrganizationBadges"; +import { DocumentTagCloud } from "@/components/DocumentTagCloud"; +import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { useDismissableLayer } from "@/components/use-dismissable-layer"; +import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isLocalNoAuthMode } from "@/lib/env"; import { appBackdrop, - clinicalNotesRow, + answerSurface, + chatMicroAction, + clinicalDivider, cn, - evidenceRow, + EmptyState, + fieldControlPlain, + fieldControlWithIcon, + fieldIcon, + floatingControl, + iconTilePremium, + metadataPill, + panelSubtle, + primaryControl, + SourceProvenance, + SourceStatusBadge, + sourceCard, + subtleStatusPill, + tableCard, + tableCardHeader, + tableMicroActionRow, textMuted, + toneDanger, + toneInfo, + toneNeutral, toneSuccess, toneWarning, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; +import { SafeBoldText } from "@/components/SafeBoldText"; +import { Sheet } from "@/components/ui/sheet"; +import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; +import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { + type SidebarIdentity, deriveSidebarIdentity, ClinicalDesktopSidebar, ClinicalMobileSidebar, @@ -52,25 +125,46 @@ import { type SetupCheck, type IngestionQualityReviewItem, } from "@/components/clinical-dashboard/DocumentManagerPanel"; -import { GuideDialog, GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; -import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text"; -import { ScopeAndGovernanceNotice } from "@/components/clinical-dashboard/answer-content"; -import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; -import { RelatedDocumentsPanel, StagedAnswerResultSurface } from "@/components/clinical-dashboard/document-results"; -import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; -import { buildMobileSectionFabState, MobileSectionFab, ToolsHub } from "@/components/clinical-dashboard/dashboard-nav"; import { - DocumentDrawer, - type DocumentDrawerMode, - type DocumentDrawerStatusFilter, - DrawerGroupLabel, - type IndexingMonitorFilter, - type LabelReviewMutationBody, - type LibraryHealthTarget, - type UploadIndexingTab, -} from "@/components/clinical-dashboard/document-admin"; + GuideDialog, + GuideTrigger, + SectionHeading, + UtilityDrawer, +} from "@/components/clinical-dashboard/dashboard-shell"; +import { + cleanDisplayTitle, + sanitizeAnswerDisplayText, + sanitizeDisplayText, +} from "@/components/clinical-dashboard/display-text"; +import { + NaturalLanguageAnswer, + ScopeAndGovernanceNotice, + SourceImage, + UserQuestionBubble, +} from "@/components/clinical-dashboard/answer-content"; +import { + AnswerFeedbackPanel, + AnswerSafetyNotice, + AnswerSupportSummaryCard, + answerHasCentralTable, + answerSupportPriority, + ClinicalNotesChecklistPanel, + clinicalNotesCount, + clinicalNotesDisplayCountForAnswer, + compactEvidenceSummary, + type EvidenceTabName, + simpleClinicalTableProps, + evidenceMapRowsFromRenderModel, + evidenceTabCount, + evidenceTabOrder, + formatQuoteCardsForClipboard, + primaryVisualTable, + QuoteCards, + SafetyFindingsPanel, +} from "@/components/clinical-dashboard/evidence-panels"; +import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; -import { errorCopy } from "@/lib/ui-copy"; +import { emptyStates, errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; const DifferentialsHome = dynamic( @@ -93,7 +187,7 @@ export const ApplicationsLauncherWorkspace = dynamic( { ssr: false }, ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; +import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -123,32 +217,50 @@ import { import { rankFormRecords } from "@/lib/forms"; import { rankServiceRecords } from "@/lib/services"; import { useRegistryRecords } from "@/lib/use-registry-records"; -import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; +import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; +import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { type SmartDocumentTag, type SmartDocumentTagFacet } from "@/lib/document-tags"; +import { smartEvidenceTags } from "@/lib/evidence-tags"; +import { + documentLabelReviewStatus, + documentLabelTier, + formatDocumentLabelDisplay, + normalizeDocumentLabelForStorage, + reviewDocumentTagQuality, + tagSearchText, + type SmartDocumentTag, + type SmartDocumentTagFacet, + type SmartDocumentTagTier, + type SmartDocumentTagQualityIssueKind, +} from "@/lib/document-tags"; import type { ClinicalDocument, + BestSourceRecommendation, DocumentMatch, EvidenceRelevance, ImportBatch, IngestionJob, + QuoteCard, RagAnswer, AnswerSection, RelatedDocument, + EvidenceSummary, SearchResult, SearchScopeSummary, + VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, + DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; +import { type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; -export const navigationHashes = ["#search", "#sources"] as const; +export const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; export const mobileSectionFabMediaQuery = "(max-width: 768px), ((max-width: 1023px) and (hover: none) and (pointer: coarse))"; @@ -218,152 +330,3157 @@ type IngestionQualityPayload = { demoMode?: boolean; }; -export const clinicalQueryModeOptions: Array<{ value: ClinicalQueryMode; label: string }> = [ - { value: "auto", label: "Auto" }, - { value: "monitoring_schedule", label: "Monitoring" }, - { value: "dose_threshold_lookup", label: "Dose / thresholds" }, - { value: "contraindications_cautions", label: "Cautions" }, - { value: "escalation_criteria", label: "Escalation" }, - { value: "required_documentation", label: "Documentation" }, - { value: "compare_guidance", label: "Compare" }, -]; +export const clinicalQueryModeOptions: Array<{ value: ClinicalQueryMode; label: string }> = [ + { value: "auto", label: "Auto" }, + { value: "monitoring_schedule", label: "Monitoring" }, + { value: "dose_threshold_lookup", label: "Dose / thresholds" }, + { value: "contraindications_cautions", label: "Cautions" }, + { value: "escalation_criteria", label: "Escalation" }, + { value: "required_documentation", label: "Documentation" }, + { value: "compare_guidance", label: "Compare" }, +]; + +function compactScopeFilters(filters: SearchScopeFilters) { + const next: SearchScopeFilters = {}; + if (filters.medications?.length) next.medications = filters.medications; + if (filters.topics?.length) next.topics = filters.topics; + if (filters.documentTypes?.length) next.documentTypes = filters.documentTypes; + if (filters.sites?.length) next.sites = filters.sites; + if (filters.services?.length) next.services = filters.services; + if (filters.settings?.length) next.settings = filters.settings; + if (filters.populations?.length) next.populations = filters.populations; + if (filters.risks?.length) next.risks = filters.risks; + if (filters.workflows?.length) next.workflows = filters.workflows; + if (filters.clinicalActions?.length) next.clinicalActions = filters.clinicalActions; + if (filters.carePhases?.length) next.carePhases = filters.carePhases; + if (filters.documentIntents?.length) next.documentIntents = filters.documentIntents; + if (filters.contentFeatures?.length) next.contentFeatures = filters.contentFeatures; + if (filters.sourceStatuses?.length) next.sourceStatuses = filters.sourceStatuses; + if (filters.validationStatuses?.length) next.validationStatuses = filters.validationStatuses; + if (filters.extractionQualities?.length) next.extractionQualities = filters.extractionQualities; + 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; +} + +type SearchResultModePayload = + | { + kind: "documents"; + query: string; + demoMode?: boolean; + sources: SearchResult[]; + documentMatches: DocumentMatch[]; + relevance?: EvidenceRelevance; + facets?: SearchFacets; + scope?: SearchScopeSummary; + sourceGovernanceWarnings?: SourceGovernanceWarning[]; + } + | { + kind: "answer"; + query: string; + payload: AnswerPayload; + }; + +type SourceLibrarySearchMode = Extract; + +function parseSseData(lines: string[]) { + const data = lines.join("\n").trim(); + if (!data) return null; + try { + return JSON.parse(data); + } catch { + throw makeSearchError("Answer stream returned malformed data.", 500, true); + } +} + +function answerStreamProgressMessage(data: unknown) { + if (!data || typeof data !== "object") return null; + const message = (data as { message?: unknown }).message; + return typeof message === "string" && message.trim() ? message.trim() : null; +} + +async function readAnswerStream(response: Response, onProgress: (message: string) => void): Promise { + if (!response.body) throw makeSearchError("Answer stream could not be opened.", undefined, true); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let finalPayload: AnswerPayload | null = null; + + function processEvent(block: string) { + const lines = block.split(/\r?\n/); + let event = "message"; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith("event:")) event = line.slice("event:".length).trim(); + if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart()); + } + + if (dataLines.length === 0) return; + const data = parseSseData(dataLines); + if (data === null) return; + if (event === "progress") { + const message = answerStreamProgressMessage(data); + if (message) onProgress(message); + return; + } + if (event === "error") { + const message = data && typeof data === "object" ? (data as { error?: unknown }).error : null; + const details = + data && typeof data === "object" ? (data as { details?: { message?: unknown } | unknown }).details : null; + const detailMessage = + details && typeof details === "object" && "message" in details && typeof details.message === "string" + ? details.message + : null; + const status = data && typeof data === "object" ? (data as { status?: unknown }).status : null; + const statusCode = typeof status === "number" ? status : undefined; + const errorMessage = + typeof message === "string" && message.trim() + ? message + : typeof detailMessage === "string" && detailMessage.trim() + ? detailMessage + : "Answer generation failed due to a streaming error."; + throw makeSearchError( + errorMessage, + statusCode, + isRetryableStatus(statusCode ?? 0) || isRetryableMessage(errorMessage), + ); + } + if (event === "final") { + finalPayload = data as AnswerPayload; + } + } + + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + + let separatorIndex = buffer.indexOf("\n\n"); + while (separatorIndex >= 0) { + const block = buffer.slice(0, separatorIndex).trim(); + buffer = buffer.slice(separatorIndex + 2); + if (block) processEvent(block); + separatorIndex = buffer.indexOf("\n\n"); + } + + if (done) break; + } + + if (buffer.trim()) processEvent(buffer.trim()); + if (!finalPayload) throw makeSearchError("Answer stream ended before a final answer was received.", undefined, true); + return finalPayload as AnswerPayload; +} + +function normalizeNavigationHash(hash: string) { + return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; +} + +function compactClinicalTableCaption(item: VisualEvidenceCard) { + const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; + const cleaned = sourceTextForCompactDisplay(raw) + .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") + .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") + .replace(/\s{2,}/g, " ") + .trim(); + const caption = cleaned || "Clinical table"; + return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; +} + +function visualEvidenceHeader(item: VisualEvidenceCard) { + const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); + const titleText = sourceTextForCompactDisplay(titleSource).trim(); + const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); + const normalizedTitle = titleText.toLowerCase(); + const normalizedCaption = captionText.toLowerCase(); + const isDuplicateCaption = + Boolean(normalizedCaption) && + (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); + return { + title: titleText || captionText || "Visual evidence", + caption: isDuplicateCaption ? null : captionText, + }; +} + +function VisualEvidenceStrip({ + evidence, + collapsed = false, + embedded = false, +}: { + evidence: VisualEvidenceCard[]; + collapsed?: boolean; + embedded?: boolean; +}) { + function looksLikeTableText(value?: string | null) { + return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); + } + + if (collapsed) { + return ( +
+ + + +
+ ); + } + + const content = ( + <> + + {evidence.length === 0 ? ( + + ) : ( +
+ {evidence.map((item) => { + const tableMarkdown = item.accessibleTableMarkdown?.trim() + ? item.accessibleTableMarkdown + : looksLikeTableText(item.tableTextSnippet) + ? item.tableTextSnippet + : null; + const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); + const tableCaption = compactClinicalTableCaption(item); + const sourceHeader = visualEvidenceHeader(item); + const displayLabels = smartEvidenceTags( + item.labels, + [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] + .filter(Boolean) + .join(" "), + ); + return ( +
+
+ +
+
+ {!hasStructuredTable ?

{sourceHeader.title}

: null} + {!hasStructuredTable && sourceHeader.caption ?

{sourceHeader.caption}

: null} + + {!hasStructuredTable && item.tableTextSnippet ? ( +

+ {sourceTextForCompactDisplay(item.tableTextSnippet)} +

+ ) : null} + {displayLabels.length ? ( +
+ {displayLabels.map((label) => ( + + {label} + + ))} +
+ ) : null} +
+
+ + {formatCompactCitationLabel(item)} + + + {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"} + + {item.image_type && ( + + {item.image_type.replaceAll("_", " ")} + + )} + {!hasStructuredTable ? : null} + + + Open source + +
+
+ ); + })} +
+ )} + + ); + + if (embedded) return
{content}
; + + return ( +
+ {content} +
+ ); +} + +function InlineTableCard({ item }: { item: VisualEvidenceCard }) { + const tableMarkdown = item.accessibleTableMarkdown?.trim() ? item.accessibleTableMarkdown : null; + const title = compactClinicalTableCaption(item); + + return ( +
+
+ {title} + {title} +
+ + + +
+
+
+ +
+
+ + Expand + + + Source + +
+
+ ); +} + +const evidenceTabIconMap: Record = { + Claims: CheckCircle2, + Quotes: Quote, + Tables: ListChecks, + Images: FileImage, + Gaps: AlertCircle, +}; + +function supportDotClass(supportLevel: string) { + const normalized = supportLevel.toLowerCase(); + if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]"; + if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) { + return "bg-[color:var(--warning)]"; + } + return "bg-[color:var(--clinical-accent)]"; +} + +function supportLabel(supportLevel: string) { + const normalized = supportLevel.toLowerCase(); + if (normalized.includes("unsupported") || normalized.includes("none")) return "Unsupported"; + if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) + return "Partial"; + return "Direct"; +} + +function claimRowsForEvidencePanel(rows: AnswerEvidenceMapRow[], renderModel: AnswerRenderModel) { + if (rows.length) return rows.slice(0, 6); + return renderModel.primarySources.slice(0, 6).map((source, index) => ({ + id: source.id, + section: source.label || cleanDisplayTitle(source.title || source.file_name) || `Source ${index + 1}`, + detail: source.snippet || source.reason || "Open source passage to review the cited evidence.", + supportLevel: source.sourceStrength === "none" ? "partial" : source.sourceStrength, + citationCount: 1, + sourceStatus: + source.sourceStrength === "none" ? "Source requires review" : `${source.sourceStrength} source support`, + bestSourceLabel: source.label, + bestLinkedPassage: source.snippet || source.reason, + href: source.href, + })); +} + +function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[]; renderModel: AnswerRenderModel }) { + const claimRows = claimRowsForEvidencePanel(rows, renderModel); + const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length; + const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length; + + if (!claimRows.length) { + return ; + } + + return ( +
+
+
+

Claims checked

+
+ + + Direct + + + + Partial + + + + Unsupported + +
+
+

+ {directCount} direct · {partialCount} partial +

+
+ +
+ {claimRows.map((row, index) => ( + + + + + {row.section} + + {row.detail || row.bestLinkedPassage || row.bestSourceLabel} + + + + + ))} +
+
+ ); +} + +function EvidenceGapsPanel({ warnings }: { warnings: string[] }) { + if (!warnings.length) { + return ( + + ); + } + + return ( +
+ {warnings.map((warning, index) => ( +
+ +
+

Gap {index + 1}

+

{warning}

+
+
+ ))} +
+ ); +} + +function MobileEvidenceSheetContent({ + answer, + sources, + renderModel, + visualEvidence, + answerEvidenceMapRows, + sourceGovernanceWarnings, + demoMode, + initialTab, + pendingFeedback, + copiedQuotes, + onCopyQuotes, + onSubmitFeedback, + onFollowUpQuote, + onScopeDocument, +}: { + answer: RagAnswer; + sources: SearchResult[]; + renderModel: AnswerRenderModel; + visualEvidence: VisualEvidenceCard[]; + answerEvidenceMapRows: AnswerEvidenceMapRow[]; + sourceGovernanceWarnings: SourceGovernanceWarning[]; + demoMode: boolean; + initialTab?: EvidenceTabName | null; + pendingFeedback: AnswerFeedbackType | null; + copiedQuotes: boolean; + onCopyQuotes: () => void; + onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; + onFollowUpQuote?: (quote: QuoteCard) => void; + onScopeDocument: (documentId: string) => void; +}) { + const order = evidenceTabOrder(answer, renderModel); + const [selectedTab, setSelectedTab] = useState(() => initialTab ?? null); + const activeTab = selectedTab && order.includes(selectedTab) ? selectedTab : order[0]; + const panelIdFor = (tab: EvidenceTabName) => `mobile-evidence-panel-${tab.toLowerCase()}`; + const [added, setAdded] = useState(false); + const primarySourceHref = renderModel.primarySources[0]?.href; + async function copyEvidence() { + if (renderModel.quoteCards.length) { + onCopyQuotes(); + return; + } + try { + await navigator.clipboard.writeText(renderModel.copyText); + } catch { + // Clipboard writes can fail in locked-down browsers; keep the panel usable. + } + } + + return ( +
+
+
+ {order.map((tab) => { + const selected = tab === activeTab; + const Icon = evidenceTabIconMap[tab]; + const count = evidenceTabCount({ + tab, + sources, + visualEvidence, + answerEvidenceMapRows, + renderModel, + }); + return ( + + ); + })} +
+
+ +
+ {order.map((tab) => { + const selected = tab === activeTab; + return ( + + ); + })} +
+ + + +
+
+ {primarySourceHref ? ( + + + Source + + ) : ( + + + Source + + )} + + +
+
+
+ ); +} + +function MobileEvidenceTabPanel({ + tab, + renderModel, + visualEvidence, + answerEvidenceMapRows, + copiedQuotes, + onCopyQuotes, + onFollowUpQuote, + onScopeDocument, +}: { + tab: EvidenceTabName; + renderModel: AnswerRenderModel; + visualEvidence: VisualEvidenceCard[]; + answerEvidenceMapRows: AnswerEvidenceMapRow[]; + copiedQuotes: boolean; + onCopyQuotes: () => void; + onFollowUpQuote?: (quote: QuoteCard) => void; + onScopeDocument: (documentId: string) => void; +}) { + if (tab === "Claims") { + return ; + } + + if (tab === "Tables") { + const tableEvidence = visualEvidence.filter((item) => item.accessibleTableMarkdown || item.tableRows?.length); + return tableEvidence.length ? ( +
+ {tableEvidence.slice(0, 4).map((item, index) => ( +
+ + + +
+

+ {compactClinicalTableCaption(item)} +

+

+ Table {index + 1} · p.{item.page_number ?? "n/a"} +

+
+ + + +
+ ))} +
+ ) : ( + + ); + } + + if (tab === "Images") { + return visualEvidence.length ? ( + + ) : ( + + ); + } + + if (tab === "Quotes") { + return ( + + ); + } + + return ; +} + +function RelatedDocumentsPanel({ + documents, + onScopeDocument, + onTagSearch, +}: { + documents: RelatedDocument[]; + onScopeDocument: (documentId: string) => void; + onTagSearch: (tag: SmartDocumentTag) => void; +}) { + if (documents.length === 0) return null; + + return ( + +
+ {documents.map((document) => ( +
+
+
+ + {documentDisplayTitle(document)} + + +

+ {document.match_reason} · pages {document.best_pages.join(", ") || "n/a"} · {document.image_count}{" "} + images{document.table_count ? ` · ${document.table_count} tables` : ""} +

+
+ +
+ {document.summary && ( +

+ +

+ )} + +
+ ))} +
+
+ ); +} + +function StagedAnswerResultSurface({ + answer, + query, + safeAnswerText, + bestSource, + sourceGovernanceWarnings, + sourceSummary, + renderModel, + weakEvidence, + answerViewMode, + answerEvidenceMapRows, + onScopeDocument, + answerGrounded, + sources, + demoMode, + safeAnswerSections, + safetyFindings, + copiedAnswer, + pendingFeedback, + onCopyAnswer, + onSubmitFeedback, +}: { + answer: RagAnswer; + query: string; + safeAnswerText: string; + bestSource: BestSourceRecommendation | null; + sourceGovernanceWarnings: SourceGovernanceWarning[]; + sourceSummary?: EvidenceSummary; + renderModel: AnswerRenderModel; + weakEvidence: boolean; + answerViewMode: AnswerViewMode; + answerEvidenceMapRows: AnswerEvidenceMapRow[]; + onScopeDocument: (documentId: string) => void; + answerGrounded: boolean; + sources: SearchResult[]; + demoMode: boolean; + safeAnswerSections: Array; + safetyFindings: ReturnType; + copiedAnswer: boolean; + pendingFeedback: AnswerFeedbackType | null; + onCopyAnswer: () => void; + onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; +}) { + const noteCount = clinicalNotesCount(answer); + const showClinicalNotes = safetyFindings.length > 0 || noteCount > 0; + const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( + answer, + answerViewMode, + noteCount || safetyFindings.length, + ); + const sourceCount = + renderModel.primarySources.length || + sourceSummary?.total_sources || + sources.length || + answer.sources?.length || + answer.citations.length; + const centralTable = answerHasCentralTable(answer) ? primaryVisualTable(answer) : null; + const showEvidenceDrawer = renderModel.allowedBlocks.some((block) => + ["sourceStatus", "reviewSources", "evidenceMap", "quoteCards", "visualEvidence", "warnings"].includes(block), + ); + const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); + const [evidenceOpen, setEvidenceOpen] = useState(false); + const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); + const [activeReviewPanel, setActiveReviewPanel] = useState<"clinical" | "evidence" | null>(null); + const [copiedQuotes, setCopiedQuotes] = useState(false); + const clinicalNotesTriggerRef = useRef(null); + const evidenceTriggerRef = useRef(null); + const useReviewSheet = useMobilePreviewSheet(); + const copyQuotesTimerRef = useRef(null); + useEffect(() => { + return () => { + if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); + }; + }, []); + function openClinicalNotes() { + setEvidenceOpen(false); + setEvidenceInitialTab(null); + if (useReviewSheet) { + setActiveReviewPanel(null); + setClinicalNotesOpen(true); + return; + } + setClinicalNotesOpen(false); + setActiveReviewPanel("clinical"); + } + function restoreFocusToTrigger(ref: RefObject) { + window.requestAnimationFrame(() => { + if (ref.current?.isConnected) ref.current.focus({ preventScroll: true }); + }); + } + function closeClinicalNotesReview() { + setClinicalNotesOpen(false); + restoreFocusToTrigger(clinicalNotesTriggerRef); + } + function openEvidence(initialTab: EvidenceTabName | null = null) { + setClinicalNotesOpen(false); + setEvidenceInitialTab(initialTab); + if (useReviewSheet) { + setActiveReviewPanel(null); + setEvidenceOpen(true); + return; + } + setEvidenceOpen(false); + setActiveReviewPanel("evidence"); + } + function closeEvidenceReview() { + setEvidenceOpen(false); + setEvidenceInitialTab(null); + restoreFocusToTrigger(evidenceTriggerRef); + } + function closeDesktopReviewPanel() { + const triggerRef = activeReviewPanel === "clinical" ? clinicalNotesTriggerRef : evidenceTriggerRef; + setActiveReviewPanel(null); + restoreFocusToTrigger(triggerRef); + } + function openTableEvidence() { + setClinicalNotesOpen(false); + openEvidence("Tables"); + } + const copyQuotes = useCallback(async () => { + const quoteText = formatQuoteCardsForClipboard(renderModel.quoteCards); + if (!quoteText) return; + try { + await navigator.clipboard.writeText(quoteText); + setCopiedQuotes(true); + if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); + copyQuotesTimerRef.current = window.setTimeout(() => setCopiedQuotes(false), 1600); + } catch { + setCopiedQuotes(false); + } + }, [renderModel.quoteCards]); + const priority = answerSupportPriority(answer, safeAnswerSections, centralTable, safetyFindings, { + grounded: answerGrounded, + weakEvidence, + }); + const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); + const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; + const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); + const showLayoutAside = Boolean(activeReviewPanel || centralTable); + + return ( +
+
+ + +
+
+ + + {showInlineSupportCard ? ( + openEvidence(null)} + /> + ) : null} + + {centralTable && activeReviewPanel ? : null} +
+ + {activeReviewPanel ? ( + + ) : centralTable ? ( +
+ +
+ ) : null} +
+ + {showClinicalNotes ? ( + + + + } + titleAccessory={ + + {clinicalNoteDisplayCount} + + } + headerActions={ + bestSource ? ( + + + + ) : null + } + headerClassName="gap-2 p-2.5 sm:p-3" + titleClassName="text-[15px] leading-5" + closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" + contentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" + contentStyle={{ height: "80dvh" }} + bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={clinicalNotesTriggerRef} + portal + > + + + ) : null} + + {showEvidenceDrawer ? ( + {evidenceTrustLabel} + } + closeLabel="Close evidence" + headerLeading={ + + + + } + contentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" + contentStyle={{ height: "80dvh" }} + bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={evidenceTriggerRef} + portal + > + + + ) : null} +
+ + +
+ ); +} + +const tagQualityTone: Record = { + noisy: toneDanger, + duplicate: toneWarning, + low_confidence: toneInfo, + overused: toneNeutral, +}; + +const labelTierTone: Record = { + primary: toneSuccess, + secondary: toneNeutral, + ranking: toneInfo, +}; + +const documentLabelTypeOptions: Array<{ value: DocumentLabelType; label: string }> = [ + { value: "site", label: "Site" }, + { value: "topic", label: "Topic" }, + { value: "document_type", label: "Document type" }, + { value: "medication", label: "Medication" }, + { value: "risk", label: "Risk" }, + { value: "setting", label: "Setting" }, + { value: "workflow", label: "Workflow" }, + { value: "population", label: "Population" }, + { value: "service", label: "Service" }, + { value: "clinical_action", label: "Clinical action" }, + { value: "care_phase", label: "Care phase" }, + { value: "document_intent", label: "Document intent" }, + { value: "content_feature", label: "Content feature" }, + { value: "custom", label: "Manual" }, +]; + +function tagQualityLabel(kind: SmartDocumentTagQualityIssueKind) { + if (kind === "low_confidence") return "low confidence"; + return kind; +} + +function normalizedLabelReviewRow(label: DocumentLabel) { + const normalized = normalizeDocumentLabelForStorage(label); + const fallbackLabelType = documentLabelTypeOptions.some((option) => option.value === label.label_type) + ? label.label_type + : "custom"; + const labelType = normalized?.label_type ?? fallbackLabelType; + const labelText = normalized?.label ?? label.label?.trim() ?? ""; + const tier: SmartDocumentTagTier = normalized + ? documentLabelTier(normalized.label, normalized.label_type) + : "secondary"; + const reviewStatus = documentLabelReviewStatus(label); + return { + id: label.id, + label: labelText, + displayLabel: labelText ? formatDocumentLabelDisplay(labelText, labelType) : "Unreviewed label", + labelType, + tier, + reviewStatus, + source: label.source, + confidence: normalized?.confidence ?? label.confidence ?? 0, + }; +} + +function labelTypeDisplay(value: DocumentLabelType) { + return documentLabelTypeOptions.find((option) => option.value === value)?.label ?? value.replaceAll("_", " "); +} + +type LabelReviewMutationBody = + { labelId: string; action: "approve" | "hide" | "restore" } | { label: string; label_type: DocumentLabelType }; + +function DocumentLabelReviewPanel({ + documents, + canManage, + onMutateLabel, +}: { + documents: ClinicalDocument[]; + canManage: boolean; + onMutateLabel: (documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody) => Promise; +}) { + const [busyAction, setBusyAction] = useState(null); + const [overrideDrafts, setOverrideDrafts] = useState>( + {}, + ); + + const items = useMemo(() => { + return documents + .map((document) => { + const rows = (document.labels ?? []) + .map((label) => normalizedLabelReviewRow(label)) + .filter((row): row is NonNullable> => Boolean(row)); + const visible = rows.filter((row) => row.reviewStatus !== "hidden" && row.tier !== "ranking"); + const ranking = rows.filter((row) => row.reviewStatus !== "hidden" && row.tier === "ranking"); + const hidden = rows.filter((row) => row.reviewStatus === "hidden"); + const needsReview = rows.some((row) => row.reviewStatus === "new" && row.source === "generated"); + return { document, rows, visible, ranking, hidden, needsReview }; + }) + .filter((item) => item.rows.length) + .sort((a, b) => Number(b.needsReview) - Number(a.needsReview) || b.ranking.length - a.ranking.length) + .slice(0, 8); + }, [documents]); + + if (!items.length) return null; + + async function mutate(documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody, actionId: string) { + setBusyAction(actionId); + try { + return await onMutateLabel(documentId, method, body); + } finally { + setBusyAction(null); + } + } + + function draftFor(documentId: string) { + return overrideDrafts[documentId] ?? { label: "", labelType: "topic" as DocumentLabelType }; + } + + function setDraft(documentId: string, next: { label: string; labelType: DocumentLabelType }) { + setOverrideDrafts((current) => ({ ...current, [documentId]: next })); + } + + return ( +
+ + + + + + + Label review + + Visible labels, ranking labels, hidden labels, confidence, and manual overrides + + + + + +
+ {items.map((item) => { + const draft = draftFor(item.document.id); + return ( +
+
+
+ + {documentDisplayTitle(item.document)} + +

+ {item.visible.length} visible · {item.ranking.length} ranking · {item.hidden.length} hidden +

+
+ {item.needsReview ? ( + Needs review + ) : ( + Reviewed + )} +
+ + {( + [ + { title: "Visible", rows: item.visible }, + { title: "Ranking", rows: item.ranking }, + { title: "Hidden", rows: item.hidden }, + ] satisfies Array<{ title: string; rows: typeof item.rows }> + ).map(({ title, rows: labelRows }) => { + if (!labelRows.length) return null; + return ( +
+

+ {title} +

+
+ {labelRows.slice(0, 8).map((label) => ( +
+
+
+ + {label.displayLabel} + + + {label.tier} + + + {labelTypeDisplay(label.labelType)} + +
+

+ {label.source} · {Math.round(label.confidence * 100)}% · {label.reviewStatus} +

+
+
+ {label.reviewStatus === "hidden" ? ( + + ) : ( + <> + + + + )} +
+
+ ))} +
+
+ ); + })} + +
{ + event.preventDefault(); + const trimmed = draft.label.trim(); + if (!trimmed) return; + void mutate( + item.document.id, + "POST", + { label: trimmed, label_type: draft.labelType }, + `override:${item.document.id}`, + ).then((ok) => { + if (ok) setDraft(item.document.id, { label: "", labelType: draft.labelType }); + }); + }} + > + setDraft(item.document.id, { ...draft, label: event.target.value })} + disabled={!canManage || busyAction !== null} + placeholder="Manual override label" + aria-label="Manual override label" + className={fieldControlPlain} + /> + + +
+
+ ); + })} +
+
+ ); +} + +function DocumentTagQualityPanel({ documents }: { documents: ClinicalDocument[] }) { + const issues = useMemo(() => reviewDocumentTagQuality(documents), [documents]); + const counts = issues.reduce>( + (current, issue) => ({ ...current, [issue.kind]: current[issue.kind] + 1 }), + { noisy: 0, duplicate: 0, low_confidence: 0, overused: 0 }, + ); + + return ( +
+ + + + + + + Tag quality review + + {issues.length + ? `${issues.length} issue${issues.length === 1 ? "" : "s"} across loaded documents` + : "No obvious tag cleanup issues in loaded documents"} + + + + + +
+
+ {(Object.keys(counts) as SmartDocumentTagQualityIssueKind[]).map((kind) => ( + + {tagQualityLabel(kind)}: {counts[kind]} + + ))} +
+ {issues.length ? ( +
+ {issues.slice(0, 12).map((issue, index) => ( +
+
+ + {tagQualityLabel(issue.kind)} + +

{issue.label}

+ {issue.count > 1 ? ( + {issue.count} hits + ) : null} +
+

{issue.reason}

+ {issue.examples.length || issue.documentTitles.length ? ( +

+ {[ + issue.examples.length ? `examples: ${issue.examples.join(", ")}` : "", + issue.documentTitles.length ? `docs: ${issue.documentTitles.join(", ")}` : "", + ] + .filter(Boolean) + .join(" · ")} +

+ ) : null} +
+ ))} +
+ ) : ( +

Loaded tags are clean enough for the current smart-tag rules.

+ )} +
+
+ ); +} + +function DocumentIndexRepairPanel({ documents }: { documents: ClinicalDocument[] }) { + const items = useMemo(() => { + return documents + .map((document) => { + const metadata = document.metadata && typeof document.metadata === "object" ? document.metadata : {}; + const score = Number((metadata as Record).index_quality_score ?? 1); + const issues = Array.isArray((metadata as Record).index_quality_issues) + ? ((metadata as Record).index_quality_issues as unknown[]).map(String) + : []; + const sectionCount = Number((metadata as Record).section_count ?? 0); + const memoryCardCount = Number((metadata as Record).memory_card_count ?? 0); + const extractionQuality = String((metadata as Record).extraction_quality ?? "unknown"); + const needsRepair = + score < 0.72 || + issues.length > 0 || + sectionCount === 0 || + memoryCardCount === 0 || + extractionQuality === "poor" || + extractionQuality === "partial"; + return { document, score, issues, sectionCount, memoryCardCount, extractionQuality, needsRepair }; + }) + .filter((item) => item.needsRepair) + .sort((a, b) => a.score - b.score || b.issues.length - a.issues.length) + .slice(0, 10); + }, [documents]); + + if (!items.length) return null; + + return ( +
+ + + + + + + Index repair queue + + {items.length} loaded document{items.length === 1 ? "" : "s"} need quality review or reindexing + + + + + +
+ {items.map((item) => ( +
+
+

{item.document.title}

+ + index {Number.isFinite(item.score) ? item.score.toFixed(2) : "n/a"} + +
+
+ extraction:{item.extractionQuality} + sections:{item.sectionCount} + memory:{item.memoryCardCount} + {item.issues.slice(0, 4).map((issue) => ( + + {issue} + + ))} +
+
+ ))} +
+
+ ); +} + +function DocumentDrawer({ + documents, + pagination, + loadingMoreDocuments, + mode, + selectedDocumentIds, + statusFilter, + onToggleScope, + onLoadMoreDocuments, + onDocumentRenamed, + onDocumentDeleted, + onBulkReindex, + onBulkAssignCollection, + onBulkMetadataUpdate, + bulkActionStatus, + bulkActionBusy, + canManageDocuments, + onTagSearch, + onMutateLabel, +}: { + documents: ClinicalDocument[]; + pagination: DocumentPagination | null; + loadingMoreDocuments: boolean; + mode: DocumentDrawerMode; + selectedDocumentIds: string[]; + statusFilter: DocumentDrawerStatusFilter; + onToggleScope: (documentId: string) => void; + onLoadMoreDocuments: () => void; + onDocumentRenamed: (document: ClinicalDocument) => void; + onDocumentDeleted: (result: DocumentDeleteResult) => void; + onBulkReindex: (mode: "enrichment" | "full" | "retry_failed") => void; + onBulkAssignCollection: (collection: string) => void; + onBulkMetadataUpdate: (metadata: Record) => void; + bulkActionStatus: string | null; + bulkActionBusy: boolean; + canManageDocuments: boolean; + onTagSearch: (tag: SmartDocumentTag) => void; + onMutateLabel: (documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody) => Promise; +}) { + const [filter, setFilter] = useState(""); + const [selectedType, setSelectedType] = useState("all"); + const [selectedSite, setSelectedSite] = useState("all"); + const [selectedTopic, setSelectedTopic] = useState("all"); + const [selectedPopulation, setSelectedPopulation] = useState("all"); + const [showNeedsReviewOnly, setShowNeedsReviewOnly] = useState(false); + + const [collectionDraft, setCollectionDraft] = useState(""); + const [metadataDraft, setMetadataDraft] = useState({ + sourceStatus: "", + validationStatus: "", + extractionQuality: "", + reviewDate: "", + publicationDate: "", + jurisdiction: "", + sourceType: "", + category: "", + }); + + const allTypes = useMemo(() => { + const types = new Set(); + for (const doc of documents) { + const typeLabel = doc.labels?.find((l) => l.label_type === "document_type" && l.confidence >= 0.5)?.label; + if (typeLabel) types.add(typeLabel); + const profile = documentOrganizationProfile(doc); + if (profile?.document_type?.label && profile.document_type.label !== "unknown") { + types.add(profile.document_type.label); + } + } + return Array.from(types).sort(); + }, [documents]); + + const allSites = useMemo(() => { + const sites = new Set(); + for (const doc of documents) { + const siteLabels = doc.labels?.filter((l) => l.label_type === "site" && l.confidence >= 0.5) ?? []; + for (const l of siteLabels) sites.add(l.label); + const profile = documentOrganizationProfile(doc); + if (profile?.site?.label) sites.add(profile.site.label); + } + return Array.from(sites).sort(); + }, [documents]); + + const allTopics = useMemo(() => { + const topics = new Set(); + for (const doc of documents) { + const topicLabels = + doc.labels?.filter((l) => (l.label_type === "topic" || l.label_type === "custom") && l.confidence >= 0.5) ?? []; + for (const l of topicLabels) topics.add(l.label); + const profile = documentOrganizationProfile(doc); + if (profile?.secondary_facets?.topic) { + for (const t of profile.secondary_facets.topic) topics.add(t); + } + } + return Array.from(topics).sort(); + }, [documents]); + + const allPopulations = useMemo(() => { + const populations = new Set(); + for (const doc of documents) { + const popLabels = doc.labels?.filter((l) => l.label_type === "population" && l.confidence >= 0.5) ?? []; + for (const l of popLabels) populations.add(l.label); + const profile = documentOrganizationProfile(doc); + if (profile?.secondary_facets?.population) { + for (const p of profile.secondary_facets.population) populations.add(p); + } + } + return Array.from(populations).sort(); + }, [documents]); + + const isAdminMode = mode === "admin" && canManageDocuments; + const filterValue = filter.toLowerCase(); + const sourcePdfCount = useMemo( + () => + documents.filter((document) => { + const typeText = `${document.file_type} ${document.file_name}`.toLowerCase(); + return documentStatusMatchesFilter(document, statusFilter) && typeText.includes("pdf"); + }).length, + [documents, statusFilter], + ); + + const filtered = documents + .filter((document) => { + if (!documentStatusMatchesFilter(document, statusFilter)) return false; + if (mode === "source") { + const typeText = `${document.file_type} ${document.file_name}`.toLowerCase(); + if (!typeText.includes("pdf")) return false; + } + + // Filter by Type + if (selectedType !== "all") { + const typeLabel = document.labels?.find((l) => l.label_type === "document_type" && l.confidence >= 0.5)?.label; + const profile = documentOrganizationProfile(document); + const hasTypeMatch = typeLabel === selectedType || profile?.document_type?.label === selectedType; + if (!hasTypeMatch) return false; + } + + // Filter by Site + if (selectedSite !== "all") { + const siteLabels = document.labels?.filter((l) => l.label_type === "site" && l.confidence >= 0.5) ?? []; + const profile = documentOrganizationProfile(document); + const hasSiteMatch = siteLabels.some((l) => l.label === selectedSite) || profile?.site?.label === selectedSite; + if (!hasSiteMatch) return false; + } + + // Filter by Topic + if (selectedTopic !== "all") { + const topicLabels = + document.labels?.filter( + (l) => (l.label_type === "topic" || l.label_type === "custom") && l.confidence >= 0.5, + ) ?? []; + const profile = documentOrganizationProfile(document); + const hasTopicMatch = + topicLabels.some((l) => l.label === selectedTopic) || + profile?.secondary_facets?.topic?.includes(selectedTopic); + if (!hasTopicMatch) return false; + } + + // Filter by Population + if (selectedPopulation !== "all") { + const popLabels = document.labels?.filter((l) => l.label_type === "population" && l.confidence >= 0.5) ?? []; + const profile = documentOrganizationProfile(document); + const hasPopMatch = + popLabels.some((l) => l.label === selectedPopulation) || + profile?.secondary_facets?.population?.includes(selectedPopulation); + if (!hasPopMatch) return false; + } + + // Filter by Needs Review + if (showNeedsReviewOnly) { + const profile = documentOrganizationProfile(document); + if (profile?.review_status !== "needs_review") return false; + } + + const labelText = tagSearchText(document); + const summaryText = document.summary?.summary ?? ""; + const haystack = `${document.title} ${document.file_name} ${labelText} ${summaryText}`.toLowerCase(); + return haystack.includes(filterValue); + }) + .sort((left, right) => { + if (mode !== "recent") return 0; + return new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime(); + }); + const availableDocumentCount = mode === "source" ? sourcePdfCount : (pagination?.total ?? documents.length); + const statusTitle = + mode === "recent" + ? `${availableDocumentCount.toLocaleString()} recent source${availableDocumentCount === 1 ? "" : "s"}` + : mode === "source" + ? `${availableDocumentCount.toLocaleString()} source PDF${availableDocumentCount === 1 ? "" : "s"}` + : isAdminMode + ? `${statusFilterLabel(statusFilter)}: ${filtered.length.toLocaleString()} shown` + : `${availableDocumentCount.toLocaleString()} indexed source${availableDocumentCount === 1 ? "" : "s"}`; + const statusHelper = + availableDocumentCount === 0 + ? mode === "recent" + ? "Recent source rows will appear here after indexing." + : mode === "source" + ? "Indexed PDF source rows will appear below." + : "Indexed source rows will appear below." + : mode === "recent" + ? "Continue reading from the most recently updated sources." + : mode === "source" + ? "Open original PDF source documents." + : "Search and filter to open indexed clinical sources."; + + return ( +
+
+ + +
+

{statusTitle}

+

{statusHelper}

+
+ + {filtered.length.toLocaleString()} shown + +
+ + + {/* Dynamic Browse Library Filters */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Admin Queue Toggle */} + {isAdminMode ? ( +
+ setShowNeedsReviewOnly(e.target.checked)} + className="rounded border-[color:var(--border)] text-[color:var(--primary)] focus:ring-[color:var(--primary)] h-4 w-4" + /> + +
+ ) : null} + {pagination && pagination.total > documents.length ? ( +

+ Showing {documents.length} of {pagination.total} documents. Load more to manage older files. +

+ ) : null} + {isAdminMode ? ( + + ) : null} + {isAdminMode ? : null} + {isAdminMode ? : null} + {isAdminMode && selectedDocumentIds.length ? ( +
+
+
+

+ {selectedDocumentIds.length} selected document{selectedDocumentIds.length === 1 ? "" : "s"} +

+

Bulk actions apply only to explicitly selected documents.

+
+
+ + + +
+
+
+ setCollectionDraft(event.target.value)} + placeholder="Collection name for selected documents" + aria-label="Collection name for selected documents" + className={fieldControlPlain} + /> + +
+
+ + Bulk metadata editor + +
+ + + + setMetadataDraft((current) => ({ ...current, reviewDate: event.target.value }))} + className={fieldControlPlain} + aria-label="Bulk review date" + /> + + setMetadataDraft((current) => ({ ...current, publicationDate: event.target.value })) + } + className={fieldControlPlain} + aria-label="Bulk publication date" + /> + setMetadataDraft((current) => ({ ...current, jurisdiction: event.target.value }))} + placeholder="Jurisdiction/locality" + aria-label="Bulk edit jurisdiction/locality" + className={fieldControlPlain} + /> + setMetadataDraft((current) => ({ ...current, sourceType: event.target.value }))} + placeholder="Source type" + aria-label="Bulk edit source type" + className={fieldControlPlain} + /> + setMetadataDraft((current) => ({ ...current, category: event.target.value }))} + placeholder="Category" + aria-label="Bulk edit category" + className={fieldControlPlain} + /> +
+ +
+ {bulkActionStatus ?

{bulkActionStatus}

: null} +
+ ) : null} +
+ {filtered.length === 0 ? ( + + ) : ( + filtered.slice(0, 12).map((document) => { + const selected = selectedDocumentIds.includes(document.id); + return ( +
+
+ + {documentDisplayTitle(document)} + + + +

+ {document.page_count} pages · {document.chunk_count} chunks · {document.image_count} images +

+ {document.summary?.summary && ( +

+ +

+ )} + + +
+
+ + + {isAdminMode ? ( + + ) : null} + +
+
+ ); + }) + )} +
+ {pagination?.hasMore ? ( + + ) : null} +
+ ); +} + +type LibraryHealthTarget = "documents" | "setup" | "indexing" | "failures"; +type DocumentDrawerMode = "recent" | "library" | "source" | "admin"; +type DocumentDrawerStatusFilter = "all" | "indexed" | "indexing" | "failed"; +type IndexingMonitorFilter = "all" | "active" | "failed"; +type UploadIndexingTab = "setup" | "upload" | "jobs" | "quality"; + +function documentStatusMatchesFilter(document: ClinicalDocument, filter: DocumentDrawerStatusFilter) { + if (filter === "all") return true; + if (filter === "indexed") return document.status === "indexed"; + if (filter === "indexing") return document.status === "queued" || document.status === "processing"; + return document.status === "failed"; +} + +function statusFilterLabel(filter: DocumentDrawerStatusFilter) { + if (filter === "indexed") return "Indexed documents"; + if (filter === "indexing") return "Indexing documents"; + if (filter === "failed") return "Failed documents"; + return "All documents"; +} + +function DrawerGroupLabel({ title }: { title: string }) { + return ( +

{title}

+ ); +} + +export function SettingsDialog({ + open, + onClose, + identity, + theme, + onToggleTheme, + onSignOut, + onOpenGuide, +}: { + open: boolean; + onClose: () => void; + identity: SidebarIdentity; + theme: "light" | "dark"; + onToggleTheme: () => void; + onSignOut: () => void; + onOpenGuide: () => void; +}) { + const closeButtonRef = useRef(null); + const settingsEmailInputRef = useRef(null); + const currentThemeLabel = theme === "dark" ? "Dark" : "Light"; + const auth = useAuthSession(); + const [settingsEmail, setSettingsEmail] = useState(""); + const [emailEntryOpen, setEmailEntryOpen] = useState(false); + const [settingsEmailAttempted, setSettingsEmailAttempted] = useState(false); + const [accountNotice, setAccountNotice] = useState(null); + const settingsAuthBusy = auth.status === "loading"; + const signedOutAccount = !identity.signedIn; + + async function submitSettingsEmail(event: FormEvent) { + event.preventDefault(); + if (!settingsEmail.trim()) return; + setAccountNotice(null); + setSettingsEmailAttempted(true); + await auth.signInWithEmail(settingsEmail.trim()); + } + + function openSettingsEmailEntry() { + setEmailEntryOpen(true); + setAccountNotice(null); + } + + function chooseSettingsProvider(provider: string) { + setAccountNotice(`${provider} sign-in is a placeholder for now. Continue with email to use this workspace.`); + } + + useEffect(() => { + if (!emailEntryOpen) return; + const focusFrame = window.requestAnimationFrame(() => { + settingsEmailInputRef.current?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(focusFrame); + }, [emailEntryOpen]); + + const settingSections = [ + { + title: "Account", + rows: [ + { icon: UserRound, label: "Profile", value: identity.displayName }, + { icon: Stethoscope, label: "Clinical role", value: "Consultant psychiatrist" }, + ], + }, + { + title: "Clinical defaults", + rows: [ + { icon: Globe2, label: "Jurisdiction", value: "Western Australia", active: true }, + { icon: CircleUserRound, label: "Default population", value: "Adults" }, + { icon: SlidersHorizontal, label: "Answer style", value: "Conservative" }, + ], + }, + { + title: "App preferences", + rows: [ + { + icon: Palette, + label: "Appearance", + value: currentThemeLabel, + onClick: onToggleTheme, + actionLabel: `Switch to ${theme === "dark" ? "light" : "dark"} mode`, + }, + { icon: SettingsIcon, label: "Interface density", value: "Comfortable" }, + ], + }, + ]; + const navItems = [ + { icon: SettingsIcon, label: "General" }, + { icon: Stethoscope, label: "Clinical defaults" }, + { icon: Sparkles, label: "Personalisation" }, + { icon: Bell, label: "Notifications" }, + { icon: LockKeyhole, label: "Security" }, + { icon: CircleUserRound, label: "Account", active: true }, + { icon: Keyboard, label: "Keyboard" }, + { + icon: HelpCircle, + label: "Help & About", + onClick: () => { + onClose(); + onOpenGuide(); + }, + }, + ]; + + const closeButton = ( + + ); + + return ( + +
+ {closeButton} + + +
+
+
+

+ Account & app +

+
+ + Clinician account + +
+ +
+

+ Clinical Guide account +

+
+ + {signedOutAccount ? : identity.initials} + {identity.signedIn ? ( + + ) : null} + +
+

+ {identity.displayName} +

+

+ {signedOutAccount ? "Sign in or create an account" : "Consultant psychiatrist, Western Australia"} +

+
+ {signedOutAccount ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ + {signedOutAccount ? ( +
+
+ + +
+ + {emailEntryOpen ? ( +
+ + +
+ ) : null} + +
+ + or continue with + +
+ +
+ chooseSettingsProvider("Apple")} /> + chooseSettingsProvider("Google")} /> + chooseSettingsProvider("Microsoft")} /> + +
+ +

+ + Accounts save preferences and search history. Do not enter PHI. +

+ + {(accountNotice || !auth.isConfigured || (settingsEmailAttempted && auth.error)) && ( +

+ {accountNotice ?? + (settingsEmailAttempted ? auth.error : null) ?? + "Supabase browser authentication is not configured for account sign-in."} +

+ )} +
+ ) : ( + + )} +
+ +
+ + + +
+ +
+
+ {settingSections.map((section) => ( +
+

+ {section.title} +

+
+ {section.rows.map((row) => ( + + ))} + {section.title === "Account" && identity.signedIn ? ( + { + onSignOut(); + onClose(); + }} + actionLabel="Sign out" + /> + ) : null} +
+
+ ))} +
+ { + onClose(); + onOpenGuide(); + }} + /> +
+
+
+
+ ); +} + +function SettingsChip({ label }: { label: string }) { + return ( + + {label} + + ); +} -function compactScopeFilters(filters: SearchScopeFilters) { - const next: SearchScopeFilters = {}; - if (filters.medications?.length) next.medications = filters.medications; - if (filters.topics?.length) next.topics = filters.topics; - if (filters.documentTypes?.length) next.documentTypes = filters.documentTypes; - if (filters.sites?.length) next.sites = filters.sites; - if (filters.services?.length) next.services = filters.services; - if (filters.settings?.length) next.settings = filters.settings; - if (filters.populations?.length) next.populations = filters.populations; - if (filters.risks?.length) next.risks = filters.risks; - if (filters.workflows?.length) next.workflows = filters.workflows; - if (filters.clinicalActions?.length) next.clinicalActions = filters.clinicalActions; - if (filters.carePhases?.length) next.carePhases = filters.carePhases; - if (filters.documentIntents?.length) next.documentIntents = filters.documentIntents; - if (filters.contentFeatures?.length) next.contentFeatures = filters.contentFeatures; - if (filters.sourceStatuses?.length) next.sourceStatuses = filters.sourceStatuses; - if (filters.validationStatuses?.length) next.validationStatuses = filters.validationStatuses; - if (filters.extractionQualities?.length) next.extractionQualities = filters.extractionQualities; - 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; +function SettingsProviderRow({ + provider, + onClick, +}: { + provider: "Apple" | "Google" | "Microsoft" | "email"; + onClick: () => void; +}) { + const label = provider === "email" ? "Use email instead" : provider; + + return ( + + ); } -type SearchResultModePayload = - | { - kind: "documents"; - query: string; - demoMode?: boolean; - sources: SearchResult[]; - documentMatches: DocumentMatch[]; - relevance?: EvidenceRelevance; - facets?: SearchFacets; - scope?: SearchScopeSummary; - sourceGovernanceWarnings?: SourceGovernanceWarning[]; - } - | { - kind: "answer"; - query: string; - payload: AnswerPayload; - }; +function SettingsProviderMark({ provider }: { provider: "Apple" | "Google" | "Microsoft" }) { + if (provider === "Microsoft") { + return ( +