diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 7e01a65e2..849e71ee3 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -41,7 +41,7 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### Shell and routing - **Root layout:** `src/app/layout.tsx` — fonts, `AuthProvider`, global CSS -- **App shell:** `src/components/clinical-dashboard/global-search-shell.tsx` — route-aware standalone shell and lazy dashboard dispatch via `global-mockup-search-shell.tsx` +- **App shell:** `src/components/clinical-dashboard/global-search-shell.tsx` — canonical route-aware shell and lazy dashboard dispatch. The mockup-named module is a compatibility re-export used only below `/mockups`. - **Home:** `src/app/page.tsx` — dashboard rendered by shell - **Dashboard:** `src/components/ClinicalDashboard.tsx` + `src/components/clinical-dashboard/` - **Modes (8):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, prescribing, tools @@ -291,7 +291,7 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: | Reindex operations | `docs/reindex-runbook.md` | | Production readiness | `docs/production-readiness-checklist.md` | | Capacity / scale-up | `docs/capacity-review.md`, `docs/auth-connection-cap-runbook.md` | -| Frontend refactor | `docs/frontend-architecture-refactor-plan.md` | +| Frontend architecture | `docs/frontend-architecture.md` | | Repo audit (2026-07-01) | `docs/audit/repo-audit-2026-07-01.md` | --- diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md new file mode 100644 index 000000000..c82ee7604 --- /dev/null +++ b/docs/frontend-architecture.md @@ -0,0 +1,22 @@ +# Frontend architecture + +## Route ownership + +- `GlobalSearchShell` owns shared navigation, responsive chrome and URL-backed mode/query/filter state. +- `ClinicalDashboard` owns submitted Answer, Documents and Prescribing workflows. +- `/documents/search` renders live `/api/search` results for submitted queries; `/documents/[id]` is the canonical viewer. +- `/documents/source*` are compatibility redirects. Fixture document journeys live only below `/mockups/document-search/**`. + +## Client state boundaries + +- Query mode and non-sensitive filters are validated by `search-navigation-context` and serialized in the URL. +- Selected private document IDs stay in session storage behind a short-lived opaque `scopeRef`; an unavailable reference blocks automatic execution rather than broadening scope. +- `AuthProvider` owns the authentication epoch and abort registry. User-scoped requests capture the epoch and must verify it before committing state. +- `answer-lifecycle` distinguishes loading, streaming, revision, completion, cancellation and failure. Cancelled provisional text is removed and cannot be copied as a final answer. + +## Server and safety boundaries + +- Pages and layouts remain Server Components unless they require browser state or event handlers. +- Route handlers enforce public/private document scope; client filters are never authorization controls. +- Pre-stream API failures use the public JSON error envelope. SSE error events are reserved for failures after a successful stream begins. +- Production routes must not import fixture/mockup modules. diff --git a/docs/site-map.md b/docs/site-map.md index 91c2f58bc..a4afa03e9 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -11,8 +11,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. - `/differentials/presentations/[slug]` - Route discovered from app directory Source: `src/app/differentials/presentations/[slug]/page.tsx`. - `/documents/search` - Documents search command centre. Source: `src/app/documents/search/page.tsx`. -- `/documents/source` - Master document reader with demo PDF content and evidence navigation. Source: `src/app/documents/source/page.tsx`. -- `/documents/source/evidence` - Evidence detail page for document flow. Source: `src/app/documents/source/evidence/page.tsx`. +- `/documents/source` - Compatibility redirect to the canonical live document viewer when a valid id is supplied. Source: `src/app/documents/source/page.tsx`. +- `/documents/source/evidence` - Compatibility redirect sharing the canonical live document viewer handoff. Source: `src/app/documents/source/evidence/page.tsx`. - `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. - `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. @@ -33,23 +33,23 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## 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` | `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/source` master reader, `/documents/source/evidence` evidence detail, `/documents/search` results, and `/documents/[id]` live viewer. | -| 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` | Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`). | +| 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` | `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/search` live results and `/documents/[id]` canonical viewer; `/documents/source*` are compatibility redirects. | +| 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` | Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`). | ## Documents flow index - `/?mode=documents` - Documents mode home. Stays as the no-query home surface for document mode. - `/documents/search?mode=documents&q=clozapine+monitoring+table&focus=1&run=1` - Documents search command centre used after submitting a search in Documents mode. -- `/documents/source?mode=documents&document=clozapine-monitoring&q=clozapine+monitoring+table&page=12&chunk=monitoring-table` - Standalone master document reader for a selected result, with bundled demo PDF content and evidence navigation. -- `/documents/source/evidence?mode=documents&document=clozapine-monitoring&evidence=monitoring-table&q=clozapine+monitoring+table&page=12&chunk=monitoring-table` - Reusable evidence detail page opened from the search tray or document reader evidence cards. +- `/documents/source?id=11111111-1111-4111-8111-111111111111&page=12&chunk=monitoring-table` - Legacy source handoff; valid document IDs redirect to the canonical live viewer and invalid IDs return to Documents search. +- `/documents/source/evidence?id=11111111-1111-4111-8111-111111111111&page=12&chunk=monitoring-table` - Legacy evidence handoff redirected to the canonical live document viewer. - `/documents/[id]` - Live document viewer route remains available for real document records. ## Registry-backed routes @@ -513,6 +513,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/document-search` - Route discovered from app directory Source: `src/app/mockups/document-search/page.tsx`. - `/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/search` - Route discovered from app directory Source: `src/app/mockups/document-search/search/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`. @@ -580,6 +582,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## Redirects +- `/documents/source` - Redirects to `/documents/search`. Source: `src/app/documents/source/page.tsx`. - `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/page.tsx`. - `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. - `/mockups/medication-prescribing` - Redirects to `/medications/acamprosate`. Source: `src/app/mockups/medication-prescribing/page.tsx`. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 3e60cb961..5fbe99fb3 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -4,12 +4,7 @@ import { pathToFileURL } from "node:url"; import { format } from "prettier"; import { appModeDefinitions, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; -import { - documentEvidenceHref, - documentReaderHref, - documentsSearchHref, - DOCUMENTS_MODE_HOME_ROUTE, -} from "@/lib/document-flow-routes"; +import { documentsSearchHref, DOCUMENTS_MODE_HOME_ROUTE } from "@/lib/document-flow-routes"; import { differentialRecords } from "@/lib/differentials"; import { formRecords } from "@/lib/forms"; import { serviceRecords } from "@/lib/services"; @@ -46,8 +41,8 @@ const routeDescriptions: Record = { "/differentials/presentations": "Presentation workflow stream.", "/documents/[id]": "Document viewer/detail page.", "/documents/search": "Documents search command centre.", - "/documents/source": "Master document reader with demo PDF content and evidence navigation.", - "/documents/source/evidence": "Evidence detail page for document flow.", + "/documents/source": "Compatibility redirect to the canonical live document viewer when a valid id is supplied.", + "/documents/source/evidence": "Compatibility redirect sharing the canonical live document viewer handoff.", "/favourites": "Saved clinical items and sets.", "/forms": "Forms home and search surface.", "/forms/[slug]": "Registry-backed form detail.", @@ -242,7 +237,7 @@ function renderModePageIndex() { home: DOCUMENTS_MODE_HOME_ROUTE, search: documentsSearchHref({ query: "lithium monitoring", focus: true, run: true }), detail: - "`/documents/source` master reader, `/documents/source/evidence` evidence detail, `/documents/search` results, and `/documents/[id]` live viewer.", + "`/documents/search` live results and `/documents/[id]` canonical viewer; `/documents/source*` are compatibility redirects.", }, { mode: "Services", @@ -291,23 +286,12 @@ function renderDocumentFlowIndex() { "Documents search command centre used after submitting a search in Documents mode.", ), bullet( - documentReaderHref({ - document: "clozapine-monitoring", - query: "clozapine monitoring table", - page: 12, - chunk: "monitoring-table", - }), - "Standalone master document reader for a selected result, with bundled demo PDF content and evidence navigation.", + "/documents/source?id=11111111-1111-4111-8111-111111111111&page=12&chunk=monitoring-table", + "Legacy source handoff; valid document IDs redirect to the canonical live viewer and invalid IDs return to Documents search.", ), bullet( - documentEvidenceHref({ - document: "clozapine-monitoring", - evidence: "monitoring-table", - query: "clozapine monitoring table", - page: 12, - chunk: "monitoring-table", - }), - "Reusable evidence detail page opened from the search tray or document reader evidence cards.", + "/documents/source/evidence?id=11111111-1111-4111-8111-111111111111&page=12&chunk=monitoring-table", + "Legacy evidence handoff redirected to the canonical live document viewer.", ), bullet("/documents/[id]", "Live document viewer route remains available for real document records."), ]; diff --git a/scripts/verify-pr-local.mjs b/scripts/verify-pr-local.mjs index 6c1fd0c4f..61d95f9a1 100644 --- a/scripts/verify-pr-local.mjs +++ b/scripts/verify-pr-local.mjs @@ -2,6 +2,46 @@ import { spawnSync } from "node:child_process"; const isWindows = process.platform === "win32"; +const baseScripts = ["check:runtime", "format:check", "lint", "typecheck", "test"]; + +function parseArgs(args) { + const options = { dryRun: false, extended: false, files: undefined }; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--extended") { + options.extended = true; + continue; + } + if (token === "--files") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--files requires a comma-separated path list."); + options.files = value; + index += 1; + continue; + } + if (token === "--help" || token === "-h") { + console.log( + "Usage: npm run verify:pr-local -- [--dry-run] [--files pathA,pathB] [--extended]\n" + + " --dry-run Print the selected checks without running them.\n" + + " --files Classify an explicit comma-separated changed-file list.\n" + + " --extended Add the local Chromium UI gate when UI files changed.", + ); + process.exit(0); + } + throw new Error(`Unknown option: ${token}`); + } + + if (options.extended && !options.dryRun && process.env.ALLOW_EXTENDED_PR_LOCAL !== "true") { + throw new Error("--extended execution requires ALLOW_EXTENDED_PR_LOCAL=true; use --dry-run to inspect the plan."); + } + + return options; +} function runNpmScript(script) { console.log(`\n> npm run ${script}`); @@ -13,8 +53,10 @@ function runNpmScript(script) { } } -function readScope() { - const result = spawnSync(process.execPath, ["scripts/ci-change-scope.mjs", "--json"], { +function readScope(files) { + const args = ["scripts/ci-change-scope.mjs", "--json"]; + if (files) args.push("--files", files); + const result = spawnSync(process.execPath, args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], }); @@ -22,19 +64,30 @@ function readScope() { return JSON.parse(result.stdout); } -const scope = readScope(); +function selectedScripts(scope, extended) { + const scripts = [...baseScripts]; + if (scope.build_changed) scripts.push("build"); + if (scope.rag_eval_changed) scripts.push("eval:rag:offline"); + if (extended && scope.ui_changed) scripts.push("verify:ui"); + return scripts; +} + +const options = parseArgs(process.argv.slice(2)); +const scope = readScope(options.files); +const scripts = selectedScripts(scope, options.extended); console.log(`Changed files: ${scope.files.length > 0 ? scope.files.join(", ") : "(none detected)"}`); -for (const script of ["check:runtime", "format:check", "lint", "typecheck", "test"]) { - runNpmScript(script); +if (options.dryRun) { + console.log("\nPR-local verification plan (dry run):"); + for (const script of scripts) console.log(`- npm run ${script}`); + if (!scope.build_changed) console.log("- build skipped: no build-affecting changes detected"); + if (!scope.rag_eval_changed) console.log("- offline RAG evaluation skipped: no RAG-affecting changes detected"); + if (options.extended && !scope.ui_changed) + console.log("- Chromium UI gate skipped: no UI-affecting changes detected"); + process.exit(0); } -if (scope.build_changed) { - runNpmScript("build"); -} else { - console.log("\nSkipping build: no build-affecting source, config, package, or container changes detected."); -} +for (const script of scripts) runNpmScript(script); -if (scope.rag_eval_changed) { - runNpmScript("eval:rag:offline"); -} +if (!scope.build_changed) + console.log("\nSkipping build: no build-affecting source, config, package, or container changes detected."); diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index e44b57281..288bd9aa2 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -5,6 +5,7 @@ import { PublicApiError, jsonError } from "@/lib/http"; import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, + rateLimitJsonResponse, type ApiRateLimitResult, } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; @@ -57,21 +58,7 @@ function encodeSse(event: string, data: unknown) { } function rateLimitStream(rateLimit: ApiRateLimitResult) { - return new Response( - encodeSse("error", { - error: "Too many answer requests. Retry shortly.", - status: 429, - details: { retryAfterSeconds: rateLimit.retryAfterSeconds, resetAt: rateLimit.resetAt }, - }), - { - status: 429, - headers: { - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-cache, no-transform", - "Retry-After": String(rateLimit.retryAfterSeconds), - }, - }, - ); + return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); } function streamErrorPayload(error: unknown) { diff --git a/src/app/documents/search/page.tsx b/src/app/documents/search/page.tsx index 10162ee0d..378cd483b 100644 --- a/src/app/documents/search/page.tsx +++ b/src/app/documents/search/page.tsx @@ -1,12 +1,21 @@ import type { Metadata } from "next"; -import { MasterDocumentSearch } from "@/components/master-document-flow-mockups"; - export const metadata: Metadata = { title: "Document Search - Clinical KB", description: "Search indexed clinical documents and review matching evidence.", }; export default function DocumentsSearchRoute() { - return ; + return ( +
+

+ Indexed library +

+

Search clinical documents

+

+ Enter a query in the Documents composer to search the live indexed library. Results open the source document at + the matching page and passage. +

+
+ ); } diff --git a/src/app/documents/source/evidence/page.tsx b/src/app/documents/source/evidence/page.tsx index 957970ca7..a56713083 100644 --- a/src/app/documents/source/evidence/page.tsx +++ b/src/app/documents/source/evidence/page.tsx @@ -1,17 +1 @@ -import type { Metadata } from "next"; -import { Suspense } from "react"; - -import { MasterEvidenceDetail } from "@/components/master-document-flow-mockups"; - -export const metadata: Metadata = { - title: "Evidence Detail - Clinical KB", - description: "Evidence detail for tables, quotes, images, and source page context.", -}; - -export default function DocumentsEvidenceRoute() { - return ( - - - - ); -} +export { metadata, default } from "@/app/documents/source/page"; diff --git a/src/app/documents/source/page.tsx b/src/app/documents/source/page.tsx index 3705ef0a0..1471d126e 100644 --- a/src/app/documents/source/page.tsx +++ b/src/app/documents/source/page.tsx @@ -1,17 +1,21 @@ import type { Metadata } from "next"; -import { Suspense } from "react"; +import { redirect } from "next/navigation"; -import { MasterDocumentReader } from "@/components/master-document-flow-mockups"; +export const metadata: Metadata = { title: "Document Source - Clinical KB" }; +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -export const metadata: Metadata = { - title: "Document Reader - Clinical KB", - description: "Document reader with highlights, bundled demo PDF content, and evidence navigation.", -}; - -export default function DocumentsSourceRoute() { - return ( - - - - ); +export default async function DocumentsSourceRoute({ + searchParams, +}: { + searchParams: Promise>; +}) { + const input = await searchParams; + const id = typeof input.id === "string" ? input.id : ""; + if (!uuidPattern.test(id)) redirect("/documents/search"); + const params = new URLSearchParams(); + const page = typeof input.page === "string" ? Number(input.page) : NaN; + const chunk = typeof input.chunk === "string" ? input.chunk.trim().slice(0, 120) : ""; + if (Number.isInteger(page) && page > 0) params.set("page", String(page)); + if (chunk) params.set("chunk", chunk); + redirect(`/documents/${id}${params.size ? `?${params.toString()}` : ""}`); } diff --git a/src/app/mockups/document-search/search/page.tsx b/src/app/mockups/document-search/search/page.tsx new file mode 100644 index 000000000..9e2326423 --- /dev/null +++ b/src/app/mockups/document-search/search/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { MasterDocumentSearch } from "@/components/master-document-flow-mockups"; + +export const metadata: Metadata = { title: "Document Search Mockup - Clinical KB" }; +export default function DocumentSearchMockupRoute() { + return ( + + + + ); +} diff --git a/src/app/mockups/document-search/source/page.tsx b/src/app/mockups/document-search/source/page.tsx new file mode 100644 index 000000000..cb9c3cf34 --- /dev/null +++ b/src/app/mockups/document-search/source/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { MasterDocumentReader } from "@/components/master-document-flow-mockups"; + +export const metadata: Metadata = { title: "Document Reader Mockup - Clinical KB" }; +export default function DocumentReaderMockupRoute() { + return ( + + + + ); +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 4007a3a7a..f6b343f77 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -23,7 +23,7 @@ import { WifiOff, Wrench, } from "lucide-react"; -import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/client-env"; @@ -164,6 +164,9 @@ import { searchSubmissionSignature, type SearchNavigationContext, } from "@/lib/search-navigation-context"; +import { persistPrivateSearchScope, restorePrivateSearchScope } from "@/lib/private-search-scope"; +import { parseApiErrorResponse } from "@/lib/api-client-error"; +import { answerLifecycleReducer, initialAnswerLifecycle } from "@/lib/answer-lifecycle"; import { rankFormRecords } from "@/lib/forms"; import { rankServiceRecords } from "@/lib/services"; import { useRegistryRecords } from "@/lib/use-registry-records"; @@ -714,7 +717,7 @@ export function ClinicalDashboard({ const scrollFrameRef = useRef(null); const navSyncLockRef = useRef(null); const autoRunSearchSignatureRef = useRef(null); - const refreshInFlightRef = useRef | null>(null); + const refreshInFlightRef = useRef<{ epoch: number; promise: Promise } | null>(null); const nextWorkStatePollRef = useRef(0); const urlSearchBootstrappedRef = useRef(false); const urlDocumentSearchBootstrappedRef = useRef(false); @@ -770,6 +773,9 @@ export function ClinicalDashboard({ : ""; const routedSearchContext = useMemo(() => readSearchNavigationContext(searchParams), [searchParams]); const routedSearchContextSignature = searchNavigationContextSignature(routedSearchContext); + const [privateScopeStatus, setPrivateScopeStatus] = useState<"none" | "restoring" | "restored" | "unavailable">( + initialSearchNavigationContext.scopeRef ? "restoring" : "none", + ); // Record matches come from the owner-scoped registry API (mock fixtures in // demo mode); ranking stays client-side so live-typing behaviour is @@ -889,6 +895,7 @@ export function ClinicalDashboard({ // `revising` = the quality gates dropped a provisional answer and are re-generating, so a // "revising for accuracy" state shows instead of stale text. const [streamingAnswer, setStreamingAnswer] = useState<{ text: string; revising: boolean } | null>(null); + const [answerLifecycle, dispatchAnswerLifecycle] = useReducer(answerLifecycleReducer, initialAnswerLifecycle); const [error, setError] = useState(null); // Companion state for `error`, used to pick the right recovery UI (retry vs. // a calm no-results panel) and to re-run the exact query that failed. Only read @@ -940,15 +947,87 @@ export function ClinicalDashboard({ const [nextRefreshDelayMs, setNextRefreshDelayMs] = useState(null); const { theme, toggleTheme } = useTheme(); const auth = useAuthSession(); - const { status: authStatus, authorizationHeader, markSessionExpired } = auth; + const { + status: authStatus, + authorizationHeader, + authEpoch, + registerAuthRequest, + isAuthEpochCurrent, + markSessionExpired, + } = auth; + const authBoundFetch = useCallback( + async (input: RequestInfo | URL, init: RequestInit = {}) => { + const controller = new AbortController(); + const authRequest = registerAuthRequest(controller); + try { + const response = await fetch(input, { ...init, signal: controller.signal }); + if (!isAuthEpochCurrent(authRequest.epoch)) throw new DOMException("Stale authentication epoch", "AbortError"); + return response; + } finally { + authRequest.release(); + } + }, + [isAuthEpochCurrent, registerAuthRequest], + ); + useEffect(() => { + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + const scopeRef = routedSearchContext.scopeRef; + if (!scopeRef) { + setPrivateScopeStatus("none"); + return; + } + if (authStatus === "loading") { + setPrivateScopeStatus("restoring"); + return; + } + const ownerId = auth.session?.user.id; + if (authStatus !== "authenticated" || !ownerId) { + setSelectedDocumentIds([]); + setPrivateScopeStatus("unavailable"); + return; + } + const restored = restorePrivateSearchScope(window.sessionStorage, scopeRef, ownerId); + if (restored.kind === "restored") { + setSelectedDocumentIds(restored.documentIds); + setPrivateScopeStatus("restored"); + } else { + setSelectedDocumentIds([]); + setPrivateScopeStatus("unavailable"); + } + }); + return () => { + cancelled = true; + }; + }, [auth.session?.user.id, authStatus, routedSearchContext.scopeRef]); const prevAuthStatusRef = useRef(authStatus); useEffect(() => { const previous = prevAuthStatusRef.current; prevAuthStatusRef.current = authStatus; if ((authStatus === "signed_out" || authStatus === "expired") && previous === "authenticated") { + searchRequestSeqRef.current += 1; + searchAbortRef.current?.abort(); + searchAbortRef.current = null; + refreshInFlightRef.current = null; resetAnswerThread(); setAnswer(null); setSources([]); + setStreamingAnswer(null); + setDocuments([]); + setDocumentsPagination(null); + setJobs([]); + setBatches([]); + setQualityItems([]); + setSelectedDocumentIds([]); + setDocumentMatches([]); + setSearchScope(null); + setSearchFacets(null); + setSourceGovernanceWarnings([]); + setActionNotice(null); + setLoading(false); + setAnswerProgress(null); + dispatchAnswerLifecycle({ type: "reset" }); latestAnswerTurnRef.current = null; } }, [authStatus, resetAnswerThread]); @@ -1116,10 +1195,14 @@ export function ClinicalDashboard({ const refresh = useCallback( async (options: RefreshOptions = {}) => { - if (refreshInFlightRef.current) { - return refreshInFlightRef.current; + if (refreshInFlightRef.current?.epoch === authEpoch) { + return refreshInFlightRef.current.promise; } + const controller = new AbortController(); + const authRequest = registerAuthRequest(controller); + const canCommit = () => isAuthEpochCurrent(authRequest.epoch) && !controller.signal.aborted; + const promise = (async () => { const trackDashboardLoading = options.includeDashboardData ?? true; await Promise.resolve(); @@ -1135,6 +1218,7 @@ export function ClinicalDashboard({ setApiUnavailable(false); const localIdentity = await readLocalProjectIdentity().catch(() => null); + if (!canCommit()) return; if (!localIdentity?.localServer?.safeLocalOrigin) { setLocalProjectReady(false); setApiUnavailable(true); @@ -1151,7 +1235,11 @@ export function ClinicalDashboard({ setLocalProjectReady(true); if (includeSetup) { - const setupResponse = await fetch("/api/setup-status", { cache: "no-store" }).catch(() => null); + const setupResponse = await fetch("/api/setup-status", { + cache: "no-store", + signal: controller.signal, + }).catch(() => null); + if (!canCommit()) return; if (!setupResponse) { if (isDeployedClinicalKb()) { @@ -1204,17 +1292,21 @@ export function ClinicalDashboard({ if (shouldRefreshWorkState) nextWorkStatePollRef.current = now + indexingWorkDetailsPollMs; const [documentsResponse, jobsResponse, batchesResponse, qualityResponse] = await Promise.all([ - fetch(`/api/documents?${documentParams.toString()}`, { headers: protectedHeaders }), + fetch(`/api/documents?${documentParams.toString()}`, { + headers: protectedHeaders, + signal: controller.signal, + }), shouldRefreshWorkState - ? fetch("/api/ingestion/jobs", { headers: protectedHeaders }) + ? fetch("/api/ingestion/jobs", { headers: protectedHeaders, signal: controller.signal }) : Promise.resolve(null as Response | null), shouldRefreshWorkState - ? fetch("/api/ingestion/batches", { headers: protectedHeaders }) + ? fetch("/api/ingestion/batches", { headers: protectedHeaders, signal: controller.signal }) : Promise.resolve(null as Response | null), shouldRefreshWorkState - ? fetch("/api/ingestion/quality", { headers: protectedHeaders }) + ? fetch("/api/ingestion/quality", { headers: protectedHeaders, signal: controller.signal }) : Promise.resolve(null as Response | null), ]); + if (!canCommit()) return; if ( documentsResponse.status === 401 || @@ -1288,17 +1380,26 @@ export function ClinicalDashboard({ setNextRefreshDelayMs(routePollDelayMs ?? (activeWork ? activeIndexingPollFallbackMs : null)); })(); - refreshInFlightRef.current = promise; + refreshInFlightRef.current = { epoch: authRequest.epoch, promise }; try { return await promise; } finally { - if ((options.includeDashboardData ?? true) === true) setDashboardDataLoading(false); - if (refreshInFlightRef.current === promise) { + authRequest.release(); + if ((options.includeDashboardData ?? true) === true && canCommit()) setDashboardDataLoading(false); + if (refreshInFlightRef.current?.promise === promise) { refreshInFlightRef.current = null; } } }, - [authorizationHeader, canUsePrivateApis, clientDemoMode, markSessionExpired], + [ + authEpoch, + authorizationHeader, + canUsePrivateApis, + clientDemoMode, + isAuthEpochCurrent, + markSessionExpired, + registerAuthRequest, + ], ); const loadMoreDocuments = useCallback(async () => { @@ -1309,7 +1410,7 @@ export function ClinicalDashboard({ setLoadingMoreDocuments(true); try { const protectedHeaders = clientDemoMode ? undefined : authorizationHeader; - const response = await fetch( + const response = await authBoundFetch( `/api/documents?limit=${documentPageSize}&offset=${documentsPagination.nextOffset}`, { headers: protectedHeaders }, ); @@ -1333,6 +1434,7 @@ export function ClinicalDashboard({ } }, [ authorizationHeader, + authBoundFetch, canUsePrivateApis, clientDemoMode, documentsPagination, @@ -1344,7 +1446,7 @@ export function ClinicalDashboard({ async (jobId: string) => { setIndexingActionId(jobId); try { - const response = await fetch(`/api/ingestion/jobs/${jobId}/retry`, { + const response = await authBoundFetch(`/api/ingestion/jobs/${jobId}/retry`, { method: "POST", headers: authorizationHeader, }); @@ -1362,6 +1464,7 @@ export function ClinicalDashboard({ }); await refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); } catch (error) { + if (isAbortError(error)) return; setActionNotice({ tone: "warning", message: error instanceof Error ? error.message : "Job retry could not be started.", @@ -1370,14 +1473,14 @@ export function ClinicalDashboard({ setIndexingActionId(null); } }, - [authorizationHeader, markSessionExpired, refresh], + [authBoundFetch, authorizationHeader, markSessionExpired, refresh], ); const reindexDocument = useCallback( async (documentId: string, mode: "full" | "enrichment" = "full") => { setIndexingActionId(documentId); try { - const response = await fetch(`/api/documents/${documentId}/reindex`, { + const response = await authBoundFetch(`/api/documents/${documentId}/reindex`, { method: "POST", headers: { ...authorizationHeader, @@ -1405,6 +1508,7 @@ export function ClinicalDashboard({ }); await refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); } catch (error) { + if (isAbortError(error)) return; setActionNotice({ tone: "warning", message: error instanceof Error ? error.message : "Document reindex could not be started.", @@ -1413,7 +1517,7 @@ export function ClinicalDashboard({ setIndexingActionId(null); } }, - [authorizationHeader, markSessionExpired, refresh], + [authBoundFetch, authorizationHeader, markSessionExpired, refresh], ); const enrichDocument = useCallback( (documentId: string) => reindexDocument(documentId, "enrichment"), @@ -1482,7 +1586,7 @@ export function ClinicalDashboard({ async (documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody) => { if (!canUsePrivateApis) return false; try { - const response = await fetch(`/api/documents/${documentId}/labels`, { + const response = await authBoundFetch(`/api/documents/${documentId}/labels`, { method, headers: { "Content-Type": "application/json", @@ -1509,12 +1613,14 @@ export function ClinicalDashboard({ } setActionNotice({ tone: "success", message: "Document label review updated." }); return true; - } catch { + } catch (error) { + if (isAbortError(error)) return false; setActionNotice({ tone: "warning", message: "Label update failed." }); return false; } }, [ + authBoundFetch, authorizationHeader, canUsePrivateApis, clientDemoMode, @@ -1790,9 +1896,7 @@ export function ClinicalDashboard({ throw makeSearchError("Search request was not authorized by the server.", 401, false); } if (!response.ok) { - const payload = await response.json().catch(() => ({})); - const message = typeof payload?.error === "string" ? payload.error : `${searchLabel} failed`; - throw makeSearchError(message, response.status, isRetryableStatus(response.status)); + throw await parseApiErrorResponse(response); } const payload = await response.json(); if (payload.demoMode) setDemoMode(true); @@ -1846,9 +1950,7 @@ export function ClinicalDashboard({ throw makeSearchError("Search request was not authorized by the server.", 401, false); } if (!response.ok) { - const payload = await response.json().catch(() => ({})); - const message = typeof payload?.error === "string" ? payload.error : "Answer generation failed"; - throw makeSearchError(message, response.status, isRetryableStatus(response.status)); + throw await parseApiErrorResponse(response); } let payload: AnswerPayload; @@ -1856,8 +1958,14 @@ export function ClinicalDashboard({ payload = await readAnswerStream( response, onProgress, - (delta) => setStreamingAnswer((prev) => ({ text: (prev?.text ?? "") + delta, revising: false })), - () => setStreamingAnswer({ text: "", revising: true }), + (delta) => { + dispatchAnswerLifecycle({ type: "stream" }); + setStreamingAnswer((prev) => ({ text: (prev?.text ?? "") + delta, revising: false })); + }, + () => { + dispatchAnswerLifecycle({ type: "revise" }); + setStreamingAnswer({ text: "", revising: true }); + }, onStreamActivity, ); } catch (error) { @@ -1875,6 +1983,7 @@ export function ClinicalDashboard({ async function runWithRetries( operation: () => Promise, onProgress: (message: string) => void = setAnswerProgress, + signal?: AbortSignal, ) { let lastError: unknown; for (let attempt = 0; attempt <= searchRetryCount; attempt += 1) { @@ -1886,7 +1995,9 @@ export function ClinicalDashboard({ const message = progressForRetry(attempt + 1); onProgress(message); - await sleep(searchRetryDelaysMs[attempt] ?? searchRetryDelaysMs[searchRetryDelaysMs.length - 1]); + const requestedDelay = (error as SearchError).retryAfterMs ?? 0; + const defaultDelay = searchRetryDelaysMs[attempt] ?? searchRetryDelaysMs[searchRetryDelaysMs.length - 1]; + await sleep(Math.max(defaultDelay, requestedDelay), signal); } } throw lastError; @@ -1912,7 +2023,13 @@ export function ClinicalDashboard({ const answerTimedOutRef = useRef(false); function stopSearch() { + searchRequestSeqRef.current += 1; searchAbortRef.current?.abort(); + searchAbortRef.current = null; + setStreamingAnswer(null); + setLoading(false); + setAnswerProgress(null); + dispatchAnswerLifecycle({ type: "cancel" }); } function applySearchResult(payload: SearchResultModePayload, displayQuery?: string, archivePreviousAnswer = true) { @@ -1972,11 +2089,17 @@ export function ClinicalDashboard({ filtersOverride = scopeFilters, queryModeOverride = queryMode, replaceExistingAnswer = false, + scopeRefOverride?: string, ) { const trimmedQuery = searchText.trim(); if (!trimmedQuery) return; const modeSearch = appModeSearchConfig(targetMode); const targetQueryMode = appModeQueryMode(targetMode, queryModeOverride); + const privateScopeRef = + scopeRefOverride ?? + (selectedDocumentIds.length > 0 && auth.session?.user.id + ? (persistPrivateSearchScope(window.sessionStorage, auth.session.user.id, selectedDocumentIds) ?? undefined) + : undefined); const isDifferentialsMode = modeSearch.resultKind === "differentials"; // Note: no automatic mode-default label scope for Services/Forms. Applying // one on every search routed resolveSearchScope's label path over the whole @@ -2043,13 +2166,19 @@ export function ClinicalDashboard({ // in-flight machinery (retry messages, keyword fallback, stream progress) // must also be discarded once a newer search takes over, or a slow stale // request repaints the progress banner under the newer query. + let requestIsCurrent = () => requestId === searchRequestSeqRef.current; const onProgress = (message: string | null) => { - if (requestId === searchRequestSeqRef.current) setAnswerProgress(message); + if (requestIsCurrent()) setAnswerProgress(message); }; // A newer search already invalidated any prior request via requestId; abort // its network work too so the server stops generating, then own the signal. searchAbortRef.current?.abort(); const abortController = new AbortController(); + const authRequest = registerAuthRequest(abortController); + requestIsCurrent = () => + requestId === searchRequestSeqRef.current && + isAuthEpochCurrent(authRequest.epoch) && + !abortController.signal.aborted; searchAbortRef.current = abortController; setLoading(true); setError(null); @@ -2066,6 +2195,7 @@ export function ClinicalDashboard({ // previous turn's question before retrieval. The raw text the user typed // is what the thread displays (via displayQuery below). const isAnswerRequest = modeSearch.resultKind === "answer"; + if (isAnswerRequest) dispatchAnswerLifecycle({ type: "start", query: trimmedQuery }); const priorTurnQuery = isAnswerRequest && !replaceExistingAnswer ? latestAnswerTurnRef.current?.query : undefined; const isAnswerFollowUp = isAnswerRequest && Boolean(priorTurnQuery); const requestQuery = isAnswerRequest ? buildAnswerFollowUpQuery(priorTurnQuery, trimmedQuery) : trimmedQuery; @@ -2115,6 +2245,7 @@ export function ClinicalDashboard({ abortController.signal, ), onProgress, + abortController.signal, ) : await runWithRetries( () => @@ -2127,6 +2258,7 @@ export function ClinicalDashboard({ answerWatchdog.touch, ), onProgress, + abortController.signal, ); if (!resultUsable(payload)) { @@ -2159,10 +2291,11 @@ export function ClinicalDashboard({ } // M10: discard a stale response — a newer search owns the UI state. - if (requestId === searchRequestSeqRef.current) { + if (requestIsCurrent()) { applySearchResult(successfulPayload, trimmedQuery, !replaceExistingAnswer); if (isDifferentialsMode) setDifferentialEvidenceQuery(trimmedQuery); if (successfulPayload.kind === "answer") { + dispatchAnswerLifecycle({ type: "complete" }); // Explicit composer submissions do not pass through the URL auto-run // effect. Seed their completed context so a later in-place route to // the same query with different intent/scope is recognized as a @@ -2170,6 +2303,7 @@ export function ClinicalDashboard({ autoRunSearchSignatureRef.current = searchSubmissionSignature(targetMode, trimmedQuery, { queryMode: targetQueryMode, scopeFilters: filtersOverride, + scopeRef: privateScopeRef, }); // The composer is a draft box in a conversation: clear it so the // user can type the next follow-up immediately. @@ -2185,6 +2319,7 @@ export function ClinicalDashboard({ run: true, queryMode: queryModeOverride, scopeFilters: filtersOverride, + scopeRef: privateScopeRef, }), ); if (isAnswerFollowUp) { @@ -2196,16 +2331,18 @@ export function ClinicalDashboard({ } } } catch (requestError) { - if (requestId === searchRequestSeqRef.current && !isAbortError(requestError)) { + if (requestIsCurrent() && !isAbortError(requestError)) { + if (isAnswerRequest) dispatchAnswerLifecycle({ type: "fail" }); setError(requestError instanceof Error ? requestError.message : "Search failed"); setErrorKind(classifyAnswerError(requestError)); setLastFailedQuery(trimmedQuery); } } finally { answerWatchdog.cancel(); + authRequest.release(); answerTimedOutRef.current = false; if (searchAbortRef.current === abortController) searchAbortRef.current = null; - if (requestId === searchRequestSeqRef.current) { + if (requestIsCurrent()) { setLoading(false); setAnswerProgress(null); } @@ -2233,6 +2370,11 @@ export function ClinicalDashboard({ const trimmedQuery = searchText.trim(); const effectiveQueryMode = contextOverride?.queryMode ?? queryMode; const effectiveScopeFilters = contextOverride?.scopeFilters ?? scopeFilters; + const privateScopeRef = + contextOverride?.scopeRef ?? + (selectedDocumentIds.length > 0 && auth.session?.user.id + ? (persistPrivateSearchScope(window.sessionStorage, auth.session.user.id, selectedDocumentIds) ?? undefined) + : undefined); if (searchMode === "documents" && trimmedQuery) { rememberRecentQuery(trimmedQuery); router.push( @@ -2242,6 +2384,7 @@ export function ClinicalDashboard({ run: true, queryMode: effectiveQueryMode, scopeFilters: effectiveScopeFilters, + scopeRef: privateScopeRef, }), ); return; @@ -2250,7 +2393,14 @@ export function ClinicalDashboard({ setMedicationSearchQuery(searchText); return; } - await executeSearch(searchText, searchMode, effectiveScopeFilters, effectiveQueryMode, replaceExistingAnswer); + await executeSearch( + searchText, + searchMode, + effectiveScopeFilters, + effectiveQueryMode, + replaceExistingAnswer, + privateScopeRef, + ); } const askRef = useRef(ask); askRef.current = ask; @@ -2260,6 +2410,7 @@ export function ClinicalDashboard({ const submittedSearchText = searchMode === "answer" && submittedUrlQuery ? submittedUrlQuery : trimmedQuery; const canAutoRunMode = searchMode === "documents" || searchMode === "prescribing" || canRunSearch; if (!autoRunSearch || !submittedSearchText || !canAutoRunMode || loading) return; + if (routedSearchContext.scopeRef && privateScopeStatus !== "restored") return; if (searchMode === "answer" && !answerThreadBootstrapped) return; const previousSignature = autoRunSearchSignatureRef.current; const signature = searchSubmissionSignature(searchMode, submittedSearchText, routedSearchContext); @@ -2294,6 +2445,7 @@ export function ClinicalDashboard({ latestAnswerQuery, routedSearchContext, routedSearchContextSignature, + privateScopeStatus, ]); function pickRecentQuery(recentQuery: string) { @@ -2527,7 +2679,7 @@ export function ClinicalDashboard({ setBulkActionBusy(true); setBulkActionStatus(null); try { - const response = await fetch("/api/documents/bulk/reindex", { + const response = await authBoundFetch("/api/documents/bulk/reindex", { method: "POST", headers: { "Content-Type": "application/json", @@ -2546,6 +2698,7 @@ export function ClinicalDashboard({ ); await refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); } catch (error) { + if (isAbortError(error)) return; setBulkActionStatus(error instanceof Error ? error.message : errorCopy.bulkReindexFailed); } finally { setBulkActionBusy(false); @@ -2562,7 +2715,7 @@ export function ClinicalDashboard({ setBulkActionBusy(true); setBulkActionStatus(null); try { - const response = await fetch("/api/documents/bulk", { + const response = await authBoundFetch("/api/documents/bulk", { method: "POST", headers: { "Content-Type": "application/json", @@ -2579,6 +2732,7 @@ export function ClinicalDashboard({ setBulkActionStatus(`${payload.updatedCount ?? 0} selected documents updated.`); await refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); } catch (error) { + if (isAbortError(error)) return; setBulkActionStatus(error instanceof Error ? error.message : errorCopy.bulkMetadataUpdateFailed); } finally { setBulkActionBusy(false); @@ -3195,6 +3349,28 @@ export function ClinicalDashboard({ [priorAnswerTurns, latestAnswerQuery], ); + function removePrivateScopeRefFromUrl() { + const params = new URLSearchParams(window.location.search); + params.delete("scopeRef"); + const next = params.toString(); + window.history.replaceState(null, "", `${window.location.pathname}${next ? `?${next}` : ""}`); + } + + function reselectUnavailablePrivateScope() { + removePrivateScopeRefFromUrl(); + setPrivateScopeStatus("none"); + setModeSearchSubmitted(false); + openSourceLibrary(); + } + + function runWithoutUnavailablePrivateScope() { + removePrivateScopeRefFromUrl(); + setSelectedDocumentIds([]); + setPrivateScopeStatus("none"); + autoRunSearchSignatureRef.current = null; + void executeSearch(submittedUrlQuery || query, searchMode, scopeFilters, queryMode, false, undefined); + } + return (
+ {privateScopeStatus === "unavailable" ? ( +
+

+ The original private document scope is unavailable. Choose the documents again or confirm a broader + search. +

+
+ + +
+
+ ) : null} +
{activeModeSearch.resultHeading} - {error && errorKind === "no-results" && activeModeResultKind === "answer" ? ( + {answerLifecycle.status === "cancelled" && activeModeResultKind === "answer" ? ( +
+
+

Generation stopped

+

No partial clinical answer was kept.

+
+ +
+ ) : error && errorKind === "no-results" && activeModeResultKind === "answer" ? (
void; }) { const inputRef = useRef(null); - const { status: authStatus, authorizationHeader, markSessionExpired } = useAuthSession(); + const { + status: authStatus, + authorizationHeader, + registerAuthRequest, + isAuthEpochCurrent, + markSessionExpired, + } = useAuthSession(); const [mode, setMode] = useState(null); const [title, setTitle] = useState(document.title); const [deleteConfirmation, setDeleteConfirmation] = useState(""); @@ -85,6 +91,8 @@ export function DocumentManagementActions({ } setPending(true); setError(null); + const controller = new AbortController(); + const authRequest = registerAuthRequest(controller); try { const response = await fetch(`/api/documents/${document.id}`, { method: "PATCH", @@ -93,13 +101,17 @@ export function DocumentManagementActions({ "Content-Type": "application/json", }, body: JSON.stringify({ title: nextTitle }), + signal: controller.signal, }); const payload = await readPayload(response); + if (!isAuthEpochCurrent(authRequest.epoch)) return; if (payload.document) onRenamed?.(payload.document as ClinicalDocument); setMode(null); } catch (renameError) { + if (!isAuthEpochCurrent(authRequest.epoch) || controller.signal.aborted) return; setError(renameError instanceof Error ? renameError.message : "Document rename failed."); } finally { + authRequest.release(); setPending(false); } } @@ -112,17 +124,23 @@ export function DocumentManagementActions({ } setPending(true); setError(null); + const controller = new AbortController(); + const authRequest = registerAuthRequest(controller); try { const response = await fetch(`/api/documents/${document.id}`, { method: "DELETE", headers: authorizationHeader, + signal: controller.signal, }); const payload = (await readPayload(response)) as DocumentDeleteResult; + if (!isAuthEpochCurrent(authRequest.epoch)) return; onDeleted?.(payload); setMode(null); } catch (deleteError) { + if (!isAuthEpochCurrent(authRequest.epoch) || controller.signal.aborted) return; setError(deleteError instanceof Error ? deleteError.message : "Document delete failed."); } finally { + authRequest.release(); setPending(false); } } diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 6bbba325a..9bcb1edd1 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2056,7 +2056,14 @@ export function DocumentViewer({ ); const [viewerModeInitialized] = useState(true); const generatedSummaryRef = useRef(null); - const { status: authStatus, isConfigured, authorizationHeader, markSessionExpired } = useAuthSession(); + const { + status: authStatus, + isConfigured, + authorizationHeader, + registerAuthRequest, + isAuthEpochCurrent, + markSessionExpired, + } = useAuthSession(); const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false); const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true"); const localNoAuthMode = isLocalNoAuthMode(); @@ -2154,6 +2161,7 @@ export function DocumentViewer({ } const controller = new AbortController(); + const authRequest = registerAuthRequest(controller); const reset = window.setTimeout(() => { if (!controller.signal.aborted) { setLoadingDocument(true); @@ -2174,6 +2182,9 @@ export function DocumentViewer({ const downloadSignedUrlEndpoint = `${signedUrlEndpoint}?download=true`; readLocalProjectIdentity() .then((identity) => { + if (!isAuthEpochCurrent(authRequest.epoch)) { + throw new DOMException("Stale authentication epoch", "AbortError"); + } if (!identity?.localServer?.safeLocalOrigin) { setLocalProjectReady(false); throw new Error(unsafeLocalProjectMessage(identity)); @@ -2217,7 +2228,7 @@ export function DocumentViewer({ return Promise.allSettled([detailRequest, signedUrlRequest, signedDownloadUrlRequest]); }) .then(([detailResult, signedUrlResult, signedDownloadUrlResult]) => { - if (controller.signal.aborted) return; + if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; if (detailResult.status === "fulfilled") { const detail = detailResult.value; @@ -2279,7 +2290,7 @@ export function DocumentViewer({ } }) .catch((error) => { - if (controller.signal.aborted) return; + if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; setViewerError(error instanceof Error ? error.message : "Document could not be loaded."); }) .finally(() => { @@ -2289,6 +2300,7 @@ export function DocumentViewer({ return () => { window.clearTimeout(reset); controller.abort(); + authRequest.release(); }; }, [ authStatus, @@ -2301,6 +2313,8 @@ export function DocumentViewer({ initialPage, isConfigured, markSessionExpired, + registerAuthRequest, + isAuthEpochCurrent, previewAttempt, ]); @@ -2316,6 +2330,7 @@ export function DocumentViewer({ } const controller = new AbortController(); + const authRequest = registerAuthRequest(controller); const timeout = window.setTimeout(() => { setSearchingDocument(true); setDocumentSearchError(null); @@ -2330,12 +2345,12 @@ export function DocumentViewer({ return payload; }) .then((payload) => { - if (controller.signal.aborted) return; + if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; setDocumentSearchResults(payload.results ?? []); setDocumentSearchError(null); }) .catch((error) => { - if (controller.signal.aborted) return; + if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return; setDocumentSearchResults([]); setDocumentSearchError(error instanceof Error ? error.message : "Document search could not be loaded."); }) @@ -2347,8 +2362,18 @@ export function DocumentViewer({ return () => { window.clearTimeout(timeout); controller.abort(); + authRequest.release(); }; - }, [authorizationHeader, canViewSourceDocuments, clientDemoMode, documentId, markSessionExpired, sourceSearch]); + }, [ + authorizationHeader, + canViewSourceDocuments, + clientDemoMode, + documentId, + isAuthEpochCurrent, + markSessionExpired, + registerAuthRequest, + sourceSearch, + ]); useEffect(() => { const updateOnline = () => setIsOnline(navigator.onLine); diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 1d0f71de6..121e64c54 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -167,12 +167,52 @@ export function SetupChecklist({ checks }: { checks: SetupCheck[] }) { * progress (fetch offers no request-body progress). Resolves on 2xx, rejects * with the server's error message otherwise. */ -function uploadFileWithProgress( +export type UploadOutcome = + | { kind: "queued"; fileName: string; documentId: string; jobId: string } + | { kind: "duplicate"; fileName: string; documentId: string; message: string } + | { kind: "failed"; fileName: string; status: number; code: string; message: string }; + +type UploadResponsePayload = { + error?: string; + message?: string; + code?: string; + duplicate?: boolean; + document?: { id?: string }; + job?: { id?: string }; +}; +export function uploadOutcomeFromResponse( + fileName: string, + status: number, + payload: UploadResponsePayload, +): UploadOutcome { + const documentId = payload.document?.id ?? ""; + if (status >= 200 && status < 300 && payload.duplicate) { + return { + kind: "duplicate", + fileName, + documentId, + message: payload.message ?? "This exact document already exists; no indexing job was queued.", + }; + } + if (status >= 200 && status < 300) { + return { kind: "queued", fileName, documentId, jobId: payload.job?.id ?? "" }; + } + return { + kind: "failed", + fileName, + status, + code: payload.code ?? `http_${status}`, + message: payload.message ?? payload.error ?? "Upload failed", + }; +} + +export function uploadFileWithProgress( file: File, authorizationHeader: Record, onProgress: (fraction: number) => void, -): Promise { - return new Promise((resolve, reject) => { + signal?: AbortSignal, +): Promise { + return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/upload"); // FormData sets its own multipart Content-Type (with boundary); only the @@ -184,7 +224,7 @@ function uploadFileWithProgress( if (event.lengthComputable && event.total > 0) onProgress(event.loaded / event.total); }; xhr.onload = () => { - let payload: { error?: string } = {}; + let payload: UploadResponsePayload = {}; try { payload = JSON.parse(xhr.responseText || "{}"); } catch { @@ -192,12 +232,28 @@ function uploadFileWithProgress( } if (xhr.status >= 200 && xhr.status < 300) { onProgress(1); - resolve(); + resolve(uploadOutcomeFromResponse(file.name, xhr.status, payload)); } else { - reject(new Error(payload.error || "Upload failed")); + resolve(uploadOutcomeFromResponse(file.name, xhr.status, payload)); } }; - xhr.onerror = () => reject(new Error(errorCopy.uploadFailed)); + xhr.onerror = () => + resolve({ + kind: "failed", + fileName: file.name, + status: 0, + code: "network_error", + message: errorCopy.uploadFailed, + }); + xhr.onabort = () => + resolve({ + kind: "failed", + fileName: file.name, + status: 0, + code: "upload_cancelled", + message: "Upload cancelled.", + }); + signal?.addEventListener("abort", () => xhr.abort(), { once: true }); const formData = new FormData(); formData.append("file", file); xhr.send(formData); @@ -209,6 +265,9 @@ export function UploadPanel({ demoMode, canUpload, authorizationHeader, + registerAuthRequest, + isAuthEpochCurrent, + onSessionExpired, status, setStatus, }: { @@ -216,6 +275,9 @@ export function UploadPanel({ demoMode: boolean; canUpload: boolean; authorizationHeader: Record; + registerAuthRequest?: (controller: AbortController) => { epoch: number; release: () => void }; + isAuthEpochCurrent?: (epoch: number) => boolean; + onSessionExpired?: () => void; status?: string | null; setStatus?: (status: string | null) => void; }) { @@ -253,7 +315,7 @@ export function UploadPanel({ files.length === 1 ? `Uploading ${files[0].name}...` : `Uploading 1 of ${files.length}: ${files[0].name}`, ); - const failures: string[] = []; + const outcomes: UploadOutcome[] = []; for (let index = 0; index < files.length; index++) { const file = files[index]; try { @@ -262,25 +324,53 @@ export function UploadPanel({ ); // Overall percent spans all files: completed files + the current file's // byte fraction, so the bar advances smoothly across a multi-file batch. - await uploadFileWithProgress(file, authorizationHeader, (fraction) => { - setUploadPercent(Math.min(100, Math.round(((index + fraction) / files.length) * 100))); - }); + const controller = new AbortController(); + const authRequest = registerAuthRequest?.(controller); + const outcome = await uploadFileWithProgress( + file, + authorizationHeader, + (fraction) => { + setUploadPercent(Math.min(100, Math.round(((index + fraction) / files.length) * 100))); + }, + controller.signal, + ); + authRequest?.release(); + if (authRequest && isAuthEpochCurrent && !isAuthEpochCurrent(authRequest.epoch)) { + changeStatus("Upload cancelled because the signed-in session changed."); + setUploading(false); + setUploadPercent(null); + return; + } + outcomes.push(outcome); + if (outcome.kind === "failed" && outcome.status === 401) onSessionExpired?.(); } catch (error) { - failures.push(`${file.name}: ${error instanceof Error ? error.message : errorCopy.uploadFailed}`); + outcomes.push({ + kind: "failed", + fileName: file.name, + status: 0, + code: "upload_failed", + message: error instanceof Error ? error.message : errorCopy.uploadFailed, + }); } } + const queued = outcomes.filter((outcome) => outcome.kind === "queued"); + const duplicates = outcomes.filter((outcome) => outcome.kind === "duplicate"); + const failures = outcomes.filter((outcome) => outcome.kind === "failed"); setUploadPercent(failures.length === 0 ? 100 : null); if (failures.length === 0) { - changeStatus( - files.length === 1 - ? "Successfully uploaded document to storage queue." - : `Successfully uploaded ${files.length} documents.`, - ); + const parts = [ + queued.length ? `${queued.length} queued for indexing` : null, + duplicates.length ? `${duplicates.length} already existed; no indexing job was queued` : null, + ].filter(Boolean); + changeStatus(parts.join(". ") + "."); if (input) input.value = ""; - onUploaded(); + if (queued.length) onUploaded(); } else { - changeStatus(`Upload complete with failures: ${failures.join("; ")}`); + const successful = queued.length + duplicates.length; + changeStatus( + `Upload complete: ${successful} accepted; ${failures.length} failed. ${failures.map((outcome) => `${outcome.fileName}: ${outcome.message}`).join("; ")}`, + ); } setUploading(false); setUploadPercent(null); @@ -333,7 +423,11 @@ export function UploadPanel({
)} {(displayStatus || demoMode) && ( -

+

{displayStatus ?? demoUploadReadOnlyMessage}

)} @@ -425,6 +519,9 @@ export function IndexingMonitor({ const busy = actionId === job.id || actionId === job.document_id; return (
+ + {documentTitle}: {job.status}, {job.progress}% +

{documentTitle}

@@ -432,7 +529,14 @@ export function IndexingMonitor({
-
+
diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index cd4148462..54c3e0fb2 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -1,602 +1,3 @@ -"use client"; - -import dynamic from "next/dynamic"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { - Suspense, - type CSSProperties, - type ReactNode, - type UIEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; -import { recentQueryStorageKey } from "@/components/clinical-dashboard/dashboard-contracts"; -import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; -import { - ClinicalDesktopSidebar, - ClinicalMobileSidebar, - deriveSidebarIdentity, -} from "@/components/clinical-dashboard/ClinicalSidebar"; -import { GuideDialog } from "@/components/clinical-dashboard/dashboard-shell"; -import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; -import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; -import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; -import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; -import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { ClientHydrationBoundary } from "@/components/client-hydration-boundary"; -import { cn } from "@/components/ui-primitives"; -import { - appModeHomeHref, - isAppModeId, - isAppModeVisible, - visibleAppModeDefinitions, - type AppModeId, -} from "@/lib/app-modes"; -import { documentsSearchHref } from "@/lib/document-flow-routes"; -import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; -import type { SearchScopeFilters } from "@/lib/search-scope"; -import { useAuthSession } from "@/lib/supabase/client"; -import type { ClinicalQueryMode } from "@/lib/types"; - -const ClinicalDashboard = dynamic( - () => import("@/components/ClinicalDashboard").then((module) => module.ClinicalDashboard), - { ssr: false, loading: () => }, -); - -const mockupQueryModeOptions: 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" }, -]; -// Re-apply focus shortly after the first frame to survive initial hydration remounts. -const focusHydrationRetryDelayMs = 300; - -type GlobalMockupSearchShellProps = { - children: ReactNode; - initialMode?: AppModeId; - availableModeIds?: readonly AppModeId[]; - desktopSearchPlacement?: "default" | "hero"; - /** Hide the shared search composer on routes that provide their own search surface. */ - searchComposerVisible?: boolean; - /** Keep the global header/search while allowing a route to use the full desktop canvas. */ - hideDesktopSidebar?: boolean; - /** Render only the mockup content when a design board needs a clean canvas. */ - chromeVisible?: boolean; - /** Hide the shared mobile header when a route owns its phone navigation. */ - mobileChromeVisible?: boolean; -}; - -export function GlobalMockupSearchShell(props: GlobalMockupSearchShellProps) { - return ( - - -
- } - > - - - ); -} - -function GlobalMockupSearchShellClient(props: GlobalMockupSearchShellProps) { - const pathname = usePathname(); - const searchParams = useSearchParams(); - const initialMode = props.initialMode ?? "answer"; - const visibleShellModes = visibleAppModeDefinitions().filter( - (mode) => !props.availableModeIds?.length || props.availableModeIds.includes(mode.id), - ); - const fallbackMode = visibleShellModes[0]?.id ?? initialMode; - const initialSearchMode = - props.availableModeIds?.length && !props.availableModeIds.includes(initialMode) ? fallbackMode : initialMode; - const requestedMode = searchParams.get("mode"); - const resolvedSearchMode = - isAppModeId(requestedMode) && - isAppModeVisible(requestedMode) && - (!props.availableModeIds?.length || props.availableModeIds.includes(requestedMode)) - ? requestedMode - : initialSearchMode; - const requestedQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim(); - const hasSubmittedModeSearch = searchParams.get("run") === "1" && requestedQuery.length > 0; - const isHomeRoute = pathname === "/"; - const isDocumentFlowRoute = pathname === "/documents/search" || pathname.startsWith("/documents/source"); - const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search") || isDocumentFlowRoute; - const shouldRenderDashboardSearch = - hasSubmittedModeSearch && - resolvedSearchMode !== "services" && - resolvedSearchMode !== "forms" && - resolvedSearchMode !== "favourites" && - resolvedSearchMode !== "differentials" && - !isDocumentSearchMockupRoute; - const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); - const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); - - if (shouldRenderClinicalDashboard) { - return ( - - ); - } - - return ; -} - -function GlobalMockupStandaloneSearchShellClient({ - children, - initialMode = "answer", - availableModeIds, - desktopSearchPlacement = "default", - searchComposerVisible = true, - hideDesktopSidebar = false, - chromeVisible = true, - mobileChromeVisible = true, -}: GlobalMockupSearchShellProps) { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const inputRef = useRef(null); - const [mainElement, setMainElement] = useState(null); - const phoneScrollHide = useScrollHideReporter(); - const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); - useEffect(() => { - reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; - }, [phoneScrollHide.reportScroll]); - const visibleShellModes = useMemo(() => { - const modes = visibleAppModeDefinitions(); - if (!availableModeIds?.length) return modes; - const allowedModeIds = new Set(availableModeIds); - return modes.filter((mode) => allowedModeIds.has(mode.id)); - }, [availableModeIds]); - const fallbackMode = visibleShellModes[0]?.id ?? initialMode; - const initialSearchMode = - availableModeIds?.length && !availableModeIds.includes(initialMode) ? fallbackMode : initialMode; - const requestedFocus = searchParams.get("focus") === "1"; - const requestedRun = searchParams.get("run") === "1"; - const currentUrlHasQuery = searchParams.has("q") || searchParams.has("query"); - const requestedQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim(); - const requestedMode = searchParams.get("mode"); - const searchParamString = searchParams.toString(); - // Mode resolved from the URL (?mode=), falling back to this shell's default when - // the param is missing, unknown, or not offered here. Seeds the initial mode and - // re-syncs it after a navigation. - const resolvedSearchMode = - isAppModeId(requestedMode) && - isAppModeVisible(requestedMode) && - (!availableModeIds?.length || availableModeIds.includes(requestedMode)) - ? requestedMode - : initialSearchMode; - const [query, setQuery] = useState(requestedQuery); - // The search string we last synced into local state, so the effect below only - // reacts to genuine navigations. Seeded with the current string so the initial - // mount is a no-op — the state above is already derived from the URL. - const lastSyncedSearchParamsRef = useRef(searchParamString); - const [searchMode, setSearchMode] = useState(resolvedSearchMode); - const [queryMode, setQueryMode] = useState( - () => readSearchNavigationContext(searchParams).queryMode, - ); - const [scopeFilters, setScopeFilters] = useState( - () => readSearchNavigationContext(searchParams).scopeFilters, - ); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); - const [guideOpen, setGuideOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); - const [accountSetupOpen, setAccountSetupOpen] = useState(false); - const [recentQueries, setRecentQueries] = useState([]); - const [commandScopes, setCommandScopes] = useState([]); - const removeCommandScope = useCallback( - (scopeId: string) => setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), - [], - ); - const clearCommandScopes = useCallback(() => setCommandScopes([]), []); - const searchCommandContextValue = useMemo( - () => ({ - query, - modeId: searchMode, - commandScopes, - onRemoveScope: removeCommandScope, - onClearScopes: clearCommandScopes, - }), - [query, searchMode, commandScopes, removeCommandScope, clearCommandScopes], - ); - const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); - const { theme, toggleTheme } = useTheme(); - const auth = useAuthSession(); - const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); - const hasSubmittedModeSearch = requestedRun && requestedQuery.length > 0; - const isDocumentFlowRoute = pathname === "/documents/search" || pathname.startsWith("/documents/source"); - const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search") || isDocumentFlowRoute; - const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; - const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; - // Services, forms, and favourites own their submitted-search views on their - // standalone routes; the shell must not swap them to the dashboard. On the - // home route the dashboard always renders, so these exclusions only apply - // to the standalone pages. - const shouldRenderDashboardSearch = - hasSubmittedModeSearch && - resolvedSearchMode !== "services" && - resolvedSearchMode !== "forms" && - resolvedSearchMode !== "favourites" && - resolvedSearchMode !== "differentials" && - !isDocumentSearchMockupRoute; - const isStandaloneModeHome = - !hasSubmittedModeSearch && - !shouldRenderDashboardSearch && - ((searchMode === "services" && pathname === "/services") || - (searchMode === "forms" && pathname === "/forms") || - (searchMode === "favourites" && pathname === "/favourites") || - (searchMode === "differentials" && pathname === "/differentials") || - (searchMode === "tools" && pathname === "/applications")); - const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); - const shouldShowDesktopSidebar = !hideDesktopSidebar; - const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; - const effectiveSidebarWidth = shouldShowDesktopSidebar ? (effectiveSidebarCollapsed ? "5.25rem" : "20rem") : "0px"; - const shouldShowSearchComposer = searchComposerVisible && !isDifferentialPresentationWorkflow; - const reservesFloatingComposer = shouldShowSearchComposer && !isStandaloneModeHome; - // Standalone mode homes portal the composer into the hero (in-flow at every - // width), so phones need no bottom-dock clearance there. - const mobileComposerReserve = !shouldShowSearchComposer - ? "2rem" - : isStandaloneModeHome - ? "2rem" - : searchMode === "answer" - ? "calc(9rem + env(safe-area-inset-bottom))" - : useCompactBottomSearch - ? "calc(5.5rem + env(safe-area-inset-bottom))" - : "calc(9rem + env(safe-area-inset-bottom))"; - - useEffect(() => { - // Re-derive the mode and query from the URL, but only when the search string - // actually changes (a real navigation). Reacting on every render — as the old - // requestAnimationFrame sync effectively did — let a deferred frame land after - // a programmatic/user fill and wipe the controlled input; on slow CI WebKit - // that raced the forms-detail composer to empty (input focused-but-empty, - // submit stuck disabled). Typing never changes the URL, so a URL-gated sync - // cannot clobber in-progress input, and the initial mount is skipped entirely - // because the state above is already seeded from the URL. - if (lastSyncedSearchParamsRef.current === searchParamString) return; - lastSyncedSearchParamsRef.current = searchParamString; - setSearchMode(resolvedSearchMode); - setQuery(currentUrlHasQuery ? requestedQuery : ""); - const nextSearchContext = readSearchNavigationContext(new URLSearchParams(searchParamString)); - setQueryMode(nextSearchContext.queryMode); - setScopeFilters(nextSearchContext.scopeFilters); - }, [currentUrlHasQuery, requestedQuery, resolvedSearchMode, searchParamString]); - - useEffect(() => { - if (!requestedFocus) return undefined; - const focusInput = () => { - inputRef.current?.focus({ preventScroll: true }); - }; - const frame = window.requestAnimationFrame(focusInput); - const timeout = window.setTimeout(focusInput, focusHydrationRetryDelayMs); - return () => { - window.cancelAnimationFrame(frame); - window.clearTimeout(timeout); - }; - }, [pathname, requestedFocus, searchParamString]); - - useEffect(() => { - let cancelled = false; - const frame = window.requestAnimationFrame(() => { - try { - const stored = JSON.parse(window.localStorage.getItem(recentQueryStorageKey) ?? "[]"); - if (Array.isArray(stored) && !cancelled) { - setRecentQueries( - stored.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).slice(0, 5), - ); - } - } catch { - if (!cancelled) setRecentQueries([]); - } - }); - return () => { - cancelled = true; - window.cancelAnimationFrame(frame); - }; - }, []); - - function prefetchApplications() { - router.prefetch("/?mode=tools"); - router.prefetch("/favourites"); - router.prefetch("/differentials"); - } - - function openGuide() { - setSettingsOpen(false); - setAccountSetupOpen(false); - setMobileMenuOpen(false); - setGuideOpen(true); - } - - function openSettings() { - setGuideOpen(false); - setAccountSetupOpen(false); - setMobileMenuOpen(false); - setSettingsOpen(true); - } - - function openAccountProfile() { - setGuideOpen(false); - setMobileMenuOpen(false); - if (sidebarIdentity.signedIn) { - setAccountSetupOpen(false); - setSettingsOpen(true); - return; - } - setSettingsOpen(false); - setAccountSetupOpen(true); - } - - function navigateToMode(mode: AppModeId, options: SearchNavigationOptions = {}) { - const nextOptions = { queryMode, scopeFilters, ...options }; - if (mode === "documents" && options.query?.trim()) { - router.push(documentsSearchHref(nextOptions)); - return; - } - router.push(appModeHomeHref(mode, nextOptions)); - } - - function submitSearch() { - const trimmedQuery = query.trim(); - navigateToMode(searchMode, { - query: trimmedQuery || undefined, - run: Boolean(trimmedQuery), - focus: true, - }); - } - - function changeMode(mode: AppModeId) { - setQuery(""); - setCommandScopes([]); - setSearchMode(mode); - setMobileMenuOpen(false); - navigateToMode(mode); - } - - function startNewAnswerChat() { - setQuery(""); - setMobileMenuOpen(false); - setSearchMode("answer"); - setQueryMode("auto"); - setScopeFilters({}); - router.push(appModeHomeHref("answer", { focus: true })); - } - - function pickRecentQuery(recentQuery: string) { - setMobileMenuOpen(false); - navigateToMode(searchMode, { query: recentQuery, focus: true, run: true }); - } - - function crossModeSearch(mode: AppModeId, crossQuery: string) { - setQuery(crossQuery); - setCommandScopes([]); - setSearchMode(mode); - setMobileMenuOpen(false); - navigateToMode(mode, { query: crossQuery, focus: true, run: true }); - } - - function handleMainScroll(event: UIEvent) { - phoneScrollHide.reportScroll(event.currentTarget.scrollTop); - } - - const mainRefCallback = (node: HTMLDivElement | null) => { - setMainElement(node); - }; - - // Page canvases can become nested scrollers when `overflow-x-hidden` pairs with - // a flex height cap (overflow-y becomes auto per CSS). Capture descendant scroll - // so the phone dock/header still hide while users scroll results. - useEffect(() => { - const main = mainElement; - if (!main) return undefined; - - const onScrollCapture = (event: Event) => { - const target = event.target; - if (!(target instanceof HTMLElement) || !main.contains(target)) return; - if (target.scrollHeight <= target.clientHeight + 1) return; - reportPhoneScrollHideRef.current(target.scrollTop); - }; - - main.addEventListener("scroll", onScrollCapture, { capture: true, passive: true }); - return () => main.removeEventListener("scroll", onScrollCapture, { capture: true }); - }, [mainElement, chromeVisible]); - - if (!chromeVisible) { - return ( -
-
- {children} -
-
- ); - } - - return ( -
- {shouldShowDesktopSidebar ? ( -
-
- -
-
- ) : null} - -
-
- { - setQuery(""); - if (isStandaloneModeHome) navigateToMode(searchMode, { focus: true }); - }} - onClearScope={() => undefined} - onQueryModeChange={setQueryMode} - onScopeFiltersChange={setScopeFilters} - onToggleScope={() => undefined} - onOpenUpload={() => - router.push(`${appModeHomeHref("documents", { focus: true, queryMode, scopeFilters })}#sources`) - } - onOpenEvidence={() => navigateToMode("answer", { focus: true })} - onNewChat={startNewAnswerChat} - onOpenMobileSidebar={() => setMobileMenuOpen(true)} - mobileLeadingAction={ - pathname === "/differentials" && searchMode === "differentials" && requestedQuery ? "back" : "menu" - } - onMobileBack={() => { - setQuery(""); - navigateToMode(searchMode, { focus: true }); - }} - queryModeOptions={mockupQueryModeOptions} - queryInputRef={inputRef} - recentQueries={recentQueries} - commandScopes={commandScopes} - onCommandScopesChange={setCommandScopes} - onPickRecent={pickRecentQuery} - onCrossModeSearch={crossModeSearch} - headerVariant={isDifferentialPresentationWorkflow ? "workflow" : "default"} - mobileSearchPlacement="bottom" - // Submitted searches that stay in the shell (services results) are - // result views: compact the phone bottom composer so results keep - // maximum screen space. Mode homes keep the chip-row layout. - mobileBottomSearchVariant={useCompactBottomSearch ? "compact" : "default"} - desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} - searchComposerVisible={shouldShowSearchComposer} - desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} - // Phone-only: #main-content owns vertical scroll, so hide-on-scroll - // collapses the header/composer to hand space back to content. - hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }} - onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden} - queryInputAutoFocus={searchParams.get("focus") === "1"} - /> -
- - -
- - setGuideOpen(false)} /> - setSettingsOpen(false)} - identity={sidebarIdentity} - theme={theme} - onToggleTheme={toggleTheme} - onSignOut={auth.signOut} - onOpenGuide={openGuide} - /> - setAccountSetupOpen(false)} /> - -
- ); -} +// Compatibility entry point for the mockup layout. Production routes import +// the canonically named implementation from global-search-shell directly. +export { GlobalSearchShell as GlobalMockupSearchShell } from "@/components/clinical-dashboard/global-search-shell"; diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 5b776ce0b..9e0643655 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -1 +1,600 @@ -export { GlobalMockupSearchShell as GlobalSearchShell } from "@/components/clinical-dashboard/global-mockup-search-shell"; +"use client"; + +import dynamic from "next/dynamic"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { + Suspense, + type CSSProperties, + type ReactNode, + type UIEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; +import { recentQueryStorageKey } from "@/components/clinical-dashboard/dashboard-contracts"; +import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; +import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; +import { + ClinicalDesktopSidebar, + ClinicalMobileSidebar, + deriveSidebarIdentity, +} from "@/components/clinical-dashboard/ClinicalSidebar"; +import { GuideDialog } from "@/components/clinical-dashboard/dashboard-shell"; +import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; +import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; +import { useTheme } from "@/components/clinical-dashboard/use-theme"; +import { ClientHydrationBoundary } from "@/components/client-hydration-boundary"; +import { cn } from "@/components/ui-primitives"; +import { + appModeHomeHref, + isAppModeId, + isAppModeVisible, + visibleAppModeDefinitions, + type AppModeId, +} from "@/lib/app-modes"; +import { documentsSearchHref } from "@/lib/document-flow-routes"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; +import type { SearchScopeFilters } from "@/lib/search-scope"; +import { useAuthSession } from "@/lib/supabase/client"; +import type { ClinicalQueryMode } from "@/lib/types"; + +const ClinicalDashboard = dynamic( + () => import("@/components/ClinicalDashboard").then((module) => module.ClinicalDashboard), + { ssr: false, loading: () => }, +); + +const mockupQueryModeOptions: 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" }, +]; +// Re-apply focus shortly after the first frame to survive initial hydration remounts. +const focusHydrationRetryDelayMs = 300; + +type GlobalSearchShellProps = { + children: ReactNode; + initialMode?: AppModeId; + availableModeIds?: readonly AppModeId[]; + desktopSearchPlacement?: "default" | "hero"; + /** Hide the shared search composer on routes that provide their own search surface. */ + searchComposerVisible?: boolean; + /** Keep the global header/search while allowing a route to use the full desktop canvas. */ + hideDesktopSidebar?: boolean; + /** Render only the mockup content when a design board needs a clean canvas. */ + chromeVisible?: boolean; + /** Hide the shared mobile header when a route owns its phone navigation. */ + mobileChromeVisible?: boolean; +}; + +export function GlobalSearchShell(props: GlobalSearchShellProps) { + return ( + + +
+ } + > + + + ); +} + +function GlobalSearchShellClient(props: GlobalSearchShellProps) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const initialMode = props.initialMode ?? "answer"; + const visibleShellModes = visibleAppModeDefinitions().filter( + (mode) => !props.availableModeIds?.length || props.availableModeIds.includes(mode.id), + ); + const fallbackMode = visibleShellModes[0]?.id ?? initialMode; + const initialSearchMode = + props.availableModeIds?.length && !props.availableModeIds.includes(initialMode) ? fallbackMode : initialMode; + const requestedMode = searchParams.get("mode"); + const resolvedSearchMode = + isAppModeId(requestedMode) && + isAppModeVisible(requestedMode) && + (!props.availableModeIds?.length || props.availableModeIds.includes(requestedMode)) + ? requestedMode + : initialSearchMode; + const requestedQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim(); + const hasSubmittedModeSearch = searchParams.get("run") === "1" && requestedQuery.length > 0; + const isHomeRoute = pathname === "/"; + const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search"); + const shouldRenderDashboardSearch = + hasSubmittedModeSearch && + resolvedSearchMode !== "services" && + resolvedSearchMode !== "forms" && + resolvedSearchMode !== "favourites" && + resolvedSearchMode !== "differentials" && + !isDocumentSearchMockupRoute; + const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); + const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); + + if (shouldRenderClinicalDashboard) { + return ( + + ); + } + + return ; +} + +function GlobalStandaloneSearchShellClient({ + children, + initialMode = "answer", + availableModeIds, + desktopSearchPlacement = "default", + searchComposerVisible = true, + hideDesktopSidebar = false, + chromeVisible = true, + mobileChromeVisible = true, +}: GlobalSearchShellProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const inputRef = useRef(null); + const [mainElement, setMainElement] = useState(null); + const phoneScrollHide = useScrollHideReporter(); + const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); + useEffect(() => { + reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; + }, [phoneScrollHide.reportScroll]); + const visibleShellModes = useMemo(() => { + const modes = visibleAppModeDefinitions(); + if (!availableModeIds?.length) return modes; + const allowedModeIds = new Set(availableModeIds); + return modes.filter((mode) => allowedModeIds.has(mode.id)); + }, [availableModeIds]); + const fallbackMode = visibleShellModes[0]?.id ?? initialMode; + const initialSearchMode = + availableModeIds?.length && !availableModeIds.includes(initialMode) ? fallbackMode : initialMode; + const requestedFocus = searchParams.get("focus") === "1"; + const requestedRun = searchParams.get("run") === "1"; + const currentUrlHasQuery = searchParams.has("q") || searchParams.has("query"); + const requestedQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim(); + const requestedMode = searchParams.get("mode"); + const searchParamString = searchParams.toString(); + // Mode resolved from the URL (?mode=), falling back to this shell's default when + // the param is missing, unknown, or not offered here. Seeds the initial mode and + // re-syncs it after a navigation. + const resolvedSearchMode = + isAppModeId(requestedMode) && + isAppModeVisible(requestedMode) && + (!availableModeIds?.length || availableModeIds.includes(requestedMode)) + ? requestedMode + : initialSearchMode; + const [query, setQuery] = useState(requestedQuery); + // The search string we last synced into local state, so the effect below only + // reacts to genuine navigations. Seeded with the current string so the initial + // mount is a no-op — the state above is already derived from the URL. + const lastSyncedSearchParamsRef = useRef(searchParamString); + const [searchMode, setSearchMode] = useState(resolvedSearchMode); + const [queryMode, setQueryMode] = useState( + () => readSearchNavigationContext(searchParams).queryMode, + ); + const [scopeFilters, setScopeFilters] = useState( + () => readSearchNavigationContext(searchParams).scopeFilters, + ); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); + const [guideOpen, setGuideOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [accountSetupOpen, setAccountSetupOpen] = useState(false); + const [recentQueries, setRecentQueries] = useState([]); + const [commandScopes, setCommandScopes] = useState([]); + const removeCommandScope = useCallback( + (scopeId: string) => setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), + [], + ); + const clearCommandScopes = useCallback(() => setCommandScopes([]), []); + const searchCommandContextValue = useMemo( + () => ({ + query, + modeId: searchMode, + commandScopes, + onRemoveScope: removeCommandScope, + onClearScopes: clearCommandScopes, + }), + [query, searchMode, commandScopes, removeCommandScope, clearCommandScopes], + ); + const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); + const { theme, toggleTheme } = useTheme(); + const auth = useAuthSession(); + const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); + const hasSubmittedModeSearch = requestedRun && requestedQuery.length > 0; + const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search"); + const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; + const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; + // Services, forms, and favourites own their submitted-search views on their + // standalone routes; the shell must not swap them to the dashboard. On the + // home route the dashboard always renders, so these exclusions only apply + // to the standalone pages. + const shouldRenderDashboardSearch = + hasSubmittedModeSearch && + resolvedSearchMode !== "services" && + resolvedSearchMode !== "forms" && + resolvedSearchMode !== "favourites" && + resolvedSearchMode !== "differentials" && + !isDocumentSearchMockupRoute; + const isStandaloneModeHome = + !hasSubmittedModeSearch && + !shouldRenderDashboardSearch && + ((searchMode === "services" && pathname === "/services") || + (searchMode === "forms" && pathname === "/forms") || + (searchMode === "favourites" && pathname === "/favourites") || + (searchMode === "differentials" && pathname === "/differentials") || + (searchMode === "tools" && pathname === "/applications")); + const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); + const shouldShowDesktopSidebar = !hideDesktopSidebar; + const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; + const effectiveSidebarWidth = shouldShowDesktopSidebar ? (effectiveSidebarCollapsed ? "5.25rem" : "20rem") : "0px"; + const shouldShowSearchComposer = searchComposerVisible && !isDifferentialPresentationWorkflow; + const reservesFloatingComposer = shouldShowSearchComposer && !isStandaloneModeHome; + // Standalone mode homes portal the composer into the hero (in-flow at every + // width), so phones need no bottom-dock clearance there. + const mobileComposerReserve = !shouldShowSearchComposer + ? "2rem" + : isStandaloneModeHome + ? "2rem" + : searchMode === "answer" + ? "calc(9rem + env(safe-area-inset-bottom))" + : useCompactBottomSearch + ? "calc(5.5rem + env(safe-area-inset-bottom))" + : "calc(9rem + env(safe-area-inset-bottom))"; + + useEffect(() => { + // Re-derive the mode and query from the URL, but only when the search string + // actually changes (a real navigation). Reacting on every render — as the old + // requestAnimationFrame sync effectively did — let a deferred frame land after + // a programmatic/user fill and wipe the controlled input; on slow CI WebKit + // that raced the forms-detail composer to empty (input focused-but-empty, + // submit stuck disabled). Typing never changes the URL, so a URL-gated sync + // cannot clobber in-progress input, and the initial mount is skipped entirely + // because the state above is already seeded from the URL. + if (lastSyncedSearchParamsRef.current === searchParamString) return; + lastSyncedSearchParamsRef.current = searchParamString; + setSearchMode(resolvedSearchMode); + setQuery(currentUrlHasQuery ? requestedQuery : ""); + const nextSearchContext = readSearchNavigationContext(new URLSearchParams(searchParamString)); + setQueryMode(nextSearchContext.queryMode); + setScopeFilters(nextSearchContext.scopeFilters); + }, [currentUrlHasQuery, requestedQuery, resolvedSearchMode, searchParamString]); + + useEffect(() => { + if (!requestedFocus) return undefined; + const focusInput = () => { + inputRef.current?.focus({ preventScroll: true }); + }; + const frame = window.requestAnimationFrame(focusInput); + const timeout = window.setTimeout(focusInput, focusHydrationRetryDelayMs); + return () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(timeout); + }; + }, [pathname, requestedFocus, searchParamString]); + + useEffect(() => { + let cancelled = false; + const frame = window.requestAnimationFrame(() => { + try { + const stored = JSON.parse(window.localStorage.getItem(recentQueryStorageKey) ?? "[]"); + if (Array.isArray(stored) && !cancelled) { + setRecentQueries( + stored.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).slice(0, 5), + ); + } + } catch { + if (!cancelled) setRecentQueries([]); + } + }); + return () => { + cancelled = true; + window.cancelAnimationFrame(frame); + }; + }, []); + + function prefetchApplications() { + router.prefetch("/?mode=tools"); + router.prefetch("/favourites"); + router.prefetch("/differentials"); + } + + function openGuide() { + setSettingsOpen(false); + setAccountSetupOpen(false); + setMobileMenuOpen(false); + setGuideOpen(true); + } + + function openSettings() { + setGuideOpen(false); + setAccountSetupOpen(false); + setMobileMenuOpen(false); + setSettingsOpen(true); + } + + function openAccountProfile() { + setGuideOpen(false); + setMobileMenuOpen(false); + if (sidebarIdentity.signedIn) { + setAccountSetupOpen(false); + setSettingsOpen(true); + return; + } + setSettingsOpen(false); + setAccountSetupOpen(true); + } + + function navigateToMode(mode: AppModeId, options: SearchNavigationOptions = {}) { + const nextOptions = { queryMode, scopeFilters, ...options }; + if (mode === "documents" && options.query?.trim()) { + router.push(documentsSearchHref(nextOptions)); + return; + } + router.push(appModeHomeHref(mode, nextOptions)); + } + + function submitSearch() { + const trimmedQuery = query.trim(); + navigateToMode(searchMode, { + query: trimmedQuery || undefined, + run: Boolean(trimmedQuery), + focus: true, + }); + } + + function changeMode(mode: AppModeId) { + setQuery(""); + setCommandScopes([]); + setSearchMode(mode); + setMobileMenuOpen(false); + navigateToMode(mode); + } + + function startNewAnswerChat() { + setQuery(""); + setMobileMenuOpen(false); + setSearchMode("answer"); + setQueryMode("auto"); + setScopeFilters({}); + router.push(appModeHomeHref("answer", { focus: true })); + } + + function pickRecentQuery(recentQuery: string) { + setMobileMenuOpen(false); + navigateToMode(searchMode, { query: recentQuery, focus: true, run: true }); + } + + function crossModeSearch(mode: AppModeId, crossQuery: string) { + setQuery(crossQuery); + setCommandScopes([]); + setSearchMode(mode); + setMobileMenuOpen(false); + navigateToMode(mode, { query: crossQuery, focus: true, run: true }); + } + + function handleMainScroll(event: UIEvent) { + phoneScrollHide.reportScroll(event.currentTarget.scrollTop); + } + + const mainRefCallback = (node: HTMLDivElement | null) => { + setMainElement(node); + }; + + // Page canvases can become nested scrollers when `overflow-x-hidden` pairs with + // a flex height cap (overflow-y becomes auto per CSS). Capture descendant scroll + // so the phone dock/header still hide while users scroll results. + useEffect(() => { + const main = mainElement; + if (!main) return undefined; + + const onScrollCapture = (event: Event) => { + const target = event.target; + if (!(target instanceof HTMLElement) || !main.contains(target)) return; + if (target.scrollHeight <= target.clientHeight + 1) return; + reportPhoneScrollHideRef.current(target.scrollTop); + }; + + main.addEventListener("scroll", onScrollCapture, { capture: true, passive: true }); + return () => main.removeEventListener("scroll", onScrollCapture, { capture: true }); + }, [mainElement, chromeVisible]); + + if (!chromeVisible) { + return ( +
+
+ {children} +
+
+ ); + } + + return ( +
+ {shouldShowDesktopSidebar ? ( +
+
+ +
+
+ ) : null} + +
+
+ { + setQuery(""); + if (isStandaloneModeHome) navigateToMode(searchMode, { focus: true }); + }} + onClearScope={() => undefined} + onQueryModeChange={setQueryMode} + onScopeFiltersChange={setScopeFilters} + onToggleScope={() => undefined} + onOpenUpload={() => + router.push(`${appModeHomeHref("documents", { focus: true, queryMode, scopeFilters })}#sources`) + } + onOpenEvidence={() => navigateToMode("answer", { focus: true })} + onNewChat={startNewAnswerChat} + onOpenMobileSidebar={() => setMobileMenuOpen(true)} + mobileLeadingAction={ + pathname === "/differentials" && searchMode === "differentials" && requestedQuery ? "back" : "menu" + } + onMobileBack={() => { + setQuery(""); + navigateToMode(searchMode, { focus: true }); + }} + queryModeOptions={mockupQueryModeOptions} + queryInputRef={inputRef} + recentQueries={recentQueries} + commandScopes={commandScopes} + onCommandScopesChange={setCommandScopes} + onPickRecent={pickRecentQuery} + onCrossModeSearch={crossModeSearch} + headerVariant={isDifferentialPresentationWorkflow ? "workflow" : "default"} + mobileSearchPlacement="bottom" + // Submitted searches that stay in the shell (services results) are + // result views: compact the phone bottom composer so results keep + // maximum screen space. Mode homes keep the chip-row layout. + mobileBottomSearchVariant={useCompactBottomSearch ? "compact" : "default"} + desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} + searchComposerVisible={shouldShowSearchComposer} + desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} + // Phone-only: #main-content owns vertical scroll, so hide-on-scroll + // collapses the header/composer to hand space back to content. + hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }} + onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden} + queryInputAutoFocus={searchParams.get("focus") === "1"} + /> +
+ + +
+ + setGuideOpen(false)} /> + setSettingsOpen(false)} + identity={sidebarIdentity} + theme={theme} + onToggleTheme={toggleTheme} + onSignOut={auth.signOut} + onOpenGuide={openGuide} + /> + setAccountSetupOpen(false)} /> + +
+ ); +} diff --git a/src/components/clinical-dashboard/search-utils.ts b/src/components/clinical-dashboard/search-utils.ts index 67ab0c142..f15800731 100644 --- a/src/components/clinical-dashboard/search-utils.ts +++ b/src/components/clinical-dashboard/search-utils.ts @@ -22,6 +22,8 @@ export function isAnswerPayload(value: unknown): value is AnswerPayload { export type SearchError = Error & { status?: number; retryable?: boolean; + code?: string; + retryAfterMs?: number | null; }; export const searchRetryDelaysMs = [500, 1000, 2000] as const; @@ -66,8 +68,22 @@ export function isRetryableError(error: unknown) { return isRetryableMessage(searchError.message); } -export function sleep(ms: number) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); +export function sleep(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const timer = window.setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + window.clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); } export function answerPayloadIsUsable(payload: AnswerPayload) { diff --git a/src/components/document-search-mockups.tsx b/src/components/document-search-mockups.tsx index 07667da69..69b088ce6 100644 --- a/src/components/document-search-mockups.tsx +++ b/src/components/document-search-mockups.tsx @@ -25,7 +25,7 @@ import { import type { ReactNode } from "react"; import { cn } from "@/components/ui-primitives"; -import { documentReaderHref } from "@/lib/document-flow-routes"; +import { mockDocumentReaderHref } from "@/lib/document-flow-routes"; export type DocumentSearchMockupVariant = "command" | "evidence-lens" | "triage-board"; @@ -97,7 +97,7 @@ const documents: DocumentFixture[] = [ ]; function highlightedDocumentHref(document: DocumentFixture) { - return documentReaderHref({ + return mockDocumentReaderHref({ document: document.slug, query: document.active ? "clozapine monitoring table" : document.title, page: document.page.replace("p.", ""), diff --git a/src/components/master-document-flow-mockups.tsx b/src/components/master-document-flow-mockups.tsx index 9d2deb647..23ae05c6f 100644 --- a/src/components/master-document-flow-mockups.tsx +++ b/src/components/master-document-flow-mockups.tsx @@ -38,10 +38,10 @@ import { DocumentFileTile, DocumentMetaRow } from "@/components/clinical-dashboa import { documentRelevancePercent } from "@/components/clinical-dashboard/relevance-score"; import { cn } from "@/components/ui-primitives"; import { - documentEvidenceHref, - documentReaderHref, + mockDocumentEvidenceHref, + mockDocumentReaderHref, + mockDocumentsSearchHref, documentSearchRequestBody, - documentsSearchHref, } from "@/lib/document-flow-routes"; import { useAuthSession } from "@/lib/supabase/client"; import type { DocumentMatch } from "@/lib/types"; @@ -248,7 +248,7 @@ const monitoringTableRows = [ ] as const; function documentHref(document: DocumentFixture, query = defaultQuery, evidence?: EvidenceFixture) { - return documentReaderHref({ + return mockDocumentReaderHref({ document: document.slug, query, page: String(evidence?.page ?? document.page), @@ -257,7 +257,7 @@ function documentHref(document: DocumentFixture, query = defaultQuery, evidence? } function evidenceHref(document: DocumentFixture, evidence: EvidenceFixture, query = defaultQuery) { - return documentEvidenceHref({ + return mockDocumentEvidenceHref({ document: document.slug, evidence: evidence.id, query, @@ -277,7 +277,7 @@ function findEvidence(document: DocumentFixture, id: string | null): EvidenceFix } function searchHref(query = defaultQuery) { - return documentsSearchHref({ query }); + return mockDocumentsSearchHref(query); } function evidenceCountLabel(document: DocumentFixture, type: EvidenceType, label: string) { diff --git a/src/lib/answer-lifecycle.ts b/src/lib/answer-lifecycle.ts new file mode 100644 index 000000000..ccd63d324 --- /dev/null +++ b/src/lib/answer-lifecycle.ts @@ -0,0 +1,30 @@ +export type AnswerLifecycleStatus = + "idle" | "loading" | "streaming" | "revising" | "completed" | "cancelled" | "failed"; +export type AnswerLifecycle = { status: AnswerLifecycleStatus; query: string | null }; +export type AnswerLifecycleEvent = + | { type: "start"; query: string } + | { type: "stream" } + | { type: "revise" } + | { type: "complete" } + | { type: "cancel" } + | { type: "fail" } + | { type: "reset" }; +export const initialAnswerLifecycle: AnswerLifecycle = { status: "idle", query: null }; +export function answerLifecycleReducer(state: AnswerLifecycle, event: AnswerLifecycleEvent): AnswerLifecycle { + switch (event.type) { + case "start": + return { status: "loading", query: event.query }; + case "stream": + return { ...state, status: "streaming" }; + case "revise": + return { ...state, status: "revising" }; + case "complete": + return { ...state, status: "completed" }; + case "cancel": + return { ...state, status: "cancelled" }; + case "fail": + return { ...state, status: "failed" }; + case "reset": + return initialAnswerLifecycle; + } +} diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts index 9f198c588..461ec358e 100644 --- a/src/lib/answer-render-policy.ts +++ b/src/lib/answer-render-policy.ts @@ -10,6 +10,7 @@ import type { SourceStrength, VisualEvidenceCard, } from "@/lib/types"; +import { formatDisplayedVisualEvidenceForClipboard } from "@/lib/ward-output"; export type AnswerRenderTrust = "unsupported" | "low" | "medium" | "high"; @@ -515,6 +516,7 @@ export function formatAnswerRenderCopyText(args: { trust: AnswerRenderTrust; primarySources: SourceLink[]; warnings: string[]; + visualEvidence?: VisualEvidenceCard[]; }) { const sourceLines = args.primarySources.length ? args.primarySources.map( @@ -539,6 +541,9 @@ export function formatAnswerRenderCopyText(args: { "", "Warnings", ...warningLines, + ...(args.visualEvidence?.length + ? ["", "Displayed table evidence", ...formatDisplayedVisualEvidenceForClipboard(args.visualEvidence)] + : []), ] .join("\n") .trim(); @@ -599,7 +604,13 @@ export function buildAnswerRenderModel( relatedDocuments, bestSource, warnings, - copyText: formatAnswerRenderCopyText({ answerText: copyAnswerText, trust, primarySources, warnings }), + copyText: formatAnswerRenderCopyText({ + answerText: copyAnswerText, + trust, + primarySources, + warnings, + visualEvidence, + }), debugReasons: options.includeDebugReasons ? decisions : undefined, }; } diff --git a/src/lib/api-client-error.ts b/src/lib/api-client-error.ts new file mode 100644 index 000000000..4cf3a8741 --- /dev/null +++ b/src/lib/api-client-error.ts @@ -0,0 +1,75 @@ +export class ApiClientError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string, + readonly retryable: boolean, + readonly retryAfterMs: number | null = null, + ) { + super(message); + this.name = "ApiClientError"; + } +} + +function retryAfterMs(response: Response, now: number) { + const raw = response.headers.get("retry-after")?.trim(); + if (!raw) return null; + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000); + const date = Date.parse(raw); + return Number.isFinite(date) ? Math.max(0, date - now) : null; +} + +function retryableStatus(status: number) { + return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504; +} + +function sseErrorPayload(text: string) { + for (const block of text.split(/\r?\n\r?\n/)) { + if (!/^event:\s*error\s*$/m.test(block)) continue; + const data = block.match(/^data:\s*(.+)$/m)?.[1]; + if (!data) continue; + try { + return JSON.parse(data) as Record; + } catch { + return null; + } + } + return null; +} + +export async function parseApiErrorResponse(response: Response, now = Date.now()) { + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + const text = await response.text().catch(() => ""); + let payload: Record | null = null; + if (contentType.includes("json")) { + try { + payload = JSON.parse(text) as Record; + } catch { + payload = null; + } + } else if (contentType.includes("text/event-stream")) { + payload = sseErrorPayload(text); + } + const message = + (typeof payload?.message === "string" && payload.message) || + (typeof payload?.error === "string" && payload.error) || + (text && !contentType.includes("text/event-stream") ? text.slice(0, 300) : "") || + `Request failed (${response.status})`; + const details = + payload?.details && typeof payload.details === "object" ? (payload.details as Record) : null; + const code = + (typeof payload?.code === "string" && payload.code) || + (typeof details?.code === "string" && details.code) || + `http_${response.status}`; + const headerDelay = retryAfterMs(response, now); + const detailsDelay = + typeof details?.retryAfterSeconds === "number" ? Math.max(0, details.retryAfterSeconds * 1000) : null; + return new ApiClientError( + message, + response.status, + code, + retryableStatus(response.status), + headerDelay ?? detailsDelay, + ); +} diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 1c1720090..7cac4a695 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -287,7 +287,10 @@ export function rateLimitJsonResponse(message: string, rateLimit: ApiRateLimitRe return NextResponse.json( { error: message, + message, + code: "rate_limited", retryAfterSeconds: rateLimit.retryAfterSeconds, + details: { retryAfterSeconds: rateLimit.retryAfterSeconds, resetAt: rateLimit.resetAt }, }, { status: 429, diff --git a/src/lib/auth-request-lifecycle.ts b/src/lib/auth-request-lifecycle.ts new file mode 100644 index 000000000..2c811b3a3 --- /dev/null +++ b/src/lib/auth-request-lifecycle.ts @@ -0,0 +1,40 @@ +export type AuthRequestRegistration = { + epoch: number; + release: () => void; +}; + +export type AuthRequestLifecycle = { + currentEpoch: () => number; + register: (controller: AbortController) => AuthRequestRegistration; + isCurrent: (epoch: number) => boolean; + invalidate: () => number; +}; + +/** + * Keeps user-scoped browser work tied to the authentication state that started it. + * Invalidating the lifecycle aborts every registered request before advancing the + * epoch, so a late response can neither continue nor commit into the next session. + */ +export function createAuthRequestLifecycle(initialEpoch = 0): AuthRequestLifecycle { + let epoch = initialEpoch; + const controllers = new Set(); + + return { + currentEpoch: () => epoch, + register(controller) { + const requestEpoch = epoch; + controllers.add(controller); + return { + epoch: requestEpoch, + release: () => controllers.delete(controller), + }; + }, + isCurrent: (requestEpoch) => requestEpoch === epoch, + invalidate() { + for (const controller of controllers) controller.abort(); + controllers.clear(); + epoch += 1; + return epoch; + }, + }; +} diff --git a/src/lib/document-flow-routes.ts b/src/lib/document-flow-routes.ts index 4a7b9d402..2b5e89813 100644 --- a/src/lib/document-flow-routes.ts +++ b/src/lib/document-flow-routes.ts @@ -8,6 +8,9 @@ export const DOCUMENTS_MODE_HOME_ROUTE = "/?mode=documents"; export const DOCUMENT_SEARCH_ROUTE = "/documents/search"; export const DOCUMENT_READER_ROUTE = "/documents/source"; export const DOCUMENT_EVIDENCE_ROUTE = "/documents/source/evidence"; +export const MOCK_DOCUMENT_SEARCH_ROUTE = "/mockups/document-search/search"; +export const MOCK_DOCUMENT_READER_ROUTE = "/mockups/document-search/source"; +export const MOCK_DOCUMENT_EVIDENCE_ROUTE = "/mockups/document-search/source/evidence"; export const DEFAULT_DOCUMENT_FLOW_QUERY = "clozapine monitoring table"; export const DEFAULT_DOCUMENT_FLOW_DOCUMENT = "clozapine-monitoring"; @@ -76,3 +79,15 @@ export function documentEvidenceHref( }); return `${DOCUMENT_EVIDENCE_ROUTE}?${params.toString()}`; } + +export function mockDocumentsSearchHref(query = DEFAULT_DOCUMENT_FLOW_QUERY) { + return `${MOCK_DOCUMENT_SEARCH_ROUTE}?${new URLSearchParams({ q: query }).toString()}`; +} + +export function mockDocumentReaderHref(options: Parameters[0] = {}) { + return documentReaderHref(options).replace(DOCUMENT_READER_ROUTE, MOCK_DOCUMENT_READER_ROUTE); +} + +export function mockDocumentEvidenceHref(options: Parameters[0] = {}) { + return documentEvidenceHref(options).replace(DOCUMENT_EVIDENCE_ROUTE, MOCK_DOCUMENT_EVIDENCE_ROUTE); +} diff --git a/src/lib/http.ts b/src/lib/http.ts index 90113b4ac..652981f52 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -86,7 +86,7 @@ export function assertAllowedFile(file: File, maxUploadMb: number) { const maxBytes = maxUploadMb * 1024 * 1024; if (file.size > maxBytes) { - throw new PublicApiError(`File exceeds ${maxUploadMb} MB upload limit.`); + throw new PublicApiError(`File exceeds ${maxUploadMb} MB upload limit.`, 413, { code: "payload_too_large" }); } } diff --git a/src/lib/private-search-scope.ts b/src/lib/private-search-scope.ts new file mode 100644 index 000000000..df9b69faf --- /dev/null +++ b/src/lib/private-search-scope.ts @@ -0,0 +1,67 @@ +const storagePrefix = "clinical.private-search-scope."; +export const privateSearchScopeTtlMs = 30 * 60 * 1000; +const maxDocumentIds = 25; +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +type StoredPrivateSearchScope = { version: 1; ownerId: string; documentIds: string[]; expiresAt: number }; +export type PrivateSearchScopeRestore = + | { kind: "restored"; documentIds: string[] } + | { kind: "unavailable"; reason: "missing" | "invalid" | "expired" | "wrong_owner" }; + +export function persistPrivateSearchScope( + storage: Pick, + ownerId: string, + documentIds: string[], + now = Date.now(), + createRef: () => string = () => crypto.randomUUID(), +) { + const validated = Array.from(new Set(documentIds)) + .filter((id) => uuidPattern.test(id)) + .slice(0, maxDocumentIds); + if (!ownerId || validated.length === 0) return null; + const scopeRef = createRef(); + if (!uuidPattern.test(scopeRef)) return null; + const value: StoredPrivateSearchScope = { + version: 1, + ownerId, + documentIds: validated, + expiresAt: now + privateSearchScopeTtlMs, + }; + storage.setItem(`${storagePrefix}${scopeRef}`, JSON.stringify(value)); + return scopeRef; +} + +export function restorePrivateSearchScope( + storage: Pick, + scopeRef: string, + ownerId: string, + now = Date.now(), +): PrivateSearchScopeRestore { + if (!uuidPattern.test(scopeRef)) return { kind: "unavailable", reason: "invalid" }; + const key = `${storagePrefix}${scopeRef}`; + const raw = storage.getItem(key); + if (!raw) return { kind: "unavailable", reason: "missing" }; + try { + const value = JSON.parse(raw) as Partial; + if ( + value.version !== 1 || + !Array.isArray(value.documentIds) || + value.documentIds.length === 0 || + value.documentIds.length > maxDocumentIds || + value.documentIds.some((id) => typeof id !== "string" || !uuidPattern.test(id)) || + typeof value.expiresAt !== "number" + ) { + storage.removeItem(key); + return { kind: "unavailable", reason: "invalid" }; + } + if (value.expiresAt <= now) { + storage.removeItem(key); + return { kind: "unavailable", reason: "expired" }; + } + if (value.ownerId !== ownerId) return { kind: "unavailable", reason: "wrong_owner" }; + return { kind: "restored", documentIds: [...new Set(value.documentIds)] }; + } catch { + storage.removeItem(key); + return { kind: "unavailable", reason: "invalid" }; + } +} diff --git a/src/lib/search-navigation-context.ts b/src/lib/search-navigation-context.ts index 896586cc9..5d4628866 100644 --- a/src/lib/search-navigation-context.ts +++ b/src/lib/search-navigation-context.ts @@ -4,6 +4,7 @@ import type { ClinicalQueryMode } from "@/lib/types"; export type SearchNavigationContext = { queryMode?: ClinicalQueryMode; scopeFilters?: SearchScopeFilters; + scopeRef?: string; }; export type SearchNavigationOptions = SearchNavigationContext & { @@ -11,6 +12,11 @@ export type SearchNavigationOptions = SearchNavigationContext & { focus?: boolean; run?: boolean; }; +export type ResolvedSearchNavigationContext = { + queryMode: ClinicalQueryMode; + scopeFilters: SearchScopeFilters; + scopeRef?: string; +}; type ReadableSearchParams = Pick; type FreeTextScopeKey = @@ -30,6 +36,7 @@ type FreeTextScopeKey = | "collections"; const queryModeParam = "queryMode"; +const privateScopeRefParam = "scopeRef"; const localityParam = "scope.locality"; const scopeParam = (key: keyof SearchScopeFilters) => `scope.${key}`; @@ -127,6 +134,7 @@ export function appendSearchNavigationContext(params: URLSearchParams, context: if (context.queryMode && context.queryMode !== "auto" && queryModes.has(context.queryMode)) { params.set(queryModeParam, context.queryMode); } + if (context.scopeRef && uuidPattern.test(context.scopeRef)) params.set(privateScopeRefParam, context.scopeRef); const filters = context.scopeFilters; if (!filters) return params; @@ -173,7 +181,7 @@ export function routedSubmissionContextChanged( return previousSignature?.startsWith(signaturePrefix) === true && previousSignature !== nextSignature; } -export function readSearchNavigationContext(params: ReadableSearchParams): Required { +export function readSearchNavigationContext(params: ReadableSearchParams): ResolvedSearchNavigationContext { const rawQueryMode = params.get(queryModeParam); const queryMode = rawQueryMode && queryModes.has(rawQueryMode as ClinicalQueryMode) ? (rawQueryMode as ClinicalQueryMode) : "auto"; @@ -215,5 +223,7 @@ export function readSearchNavigationContext(params: ReadableSearchParams): Requi scopeFilters.locality = locality as NonNullable; } - return { queryMode, scopeFilters }; + const rawScopeRef = params.get(privateScopeRefParam)?.trim(); + const scopeRef = rawScopeRef && uuidPattern.test(rawScopeRef) ? rawScopeRef : undefined; + return { queryMode, scopeFilters, scopeRef }; } diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx index 6f3ea88b4..9b40b93e5 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -2,8 +2,9 @@ import { createBrowserClient } from "@supabase/ssr"; import { type Session, type SupabaseClient } from "@supabase/supabase-js"; -import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { clearPersistedAnswerThread } from "@/lib/answer-thread-storage"; +import { createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; type AuthStatus = "unconfigured" | "loading" | "signed_out" | "authenticated" | "expired" | "error"; @@ -18,6 +19,9 @@ type AuthContextValue = { notice: string | null; isConfigured: boolean; authorizationHeader: Record; + authEpoch: number; + registerAuthRequest: (controller: AbortController) => { epoch: number; release: () => void }; + isAuthEpochCurrent: (epoch: number) => boolean; signInWithEmail: (email: string) => Promise; signInWithPassword: (email: string, password: string) => Promise; signUpWithPassword: (email: string, password: string) => Promise; @@ -99,6 +103,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [status, setStatus] = useState(client ? "loading" : "unconfigured"); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); + const authRequestsRef = useRef(createAuthRequestLifecycle()); + const authFingerprintRef = useRef(null); + const [authEpoch, setAuthEpoch] = useState(0); + + const invalidateAuthRequests = useCallback(() => { + setAuthEpoch(authRequestsRef.current.invalidate()); + }, []); + const registerAuthRequest = useCallback((controller: AbortController) => { + return authRequestsRef.current.register(controller); + }, []); + const isAuthEpochCurrent = useCallback((epoch: number) => authRequestsRef.current.isCurrent(epoch), []); useEffect(() => { if (!client) return () => undefined; @@ -247,25 +262,38 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signOut = useCallback(async () => { if (!client) return; + invalidateAuthRequests(); await client.auth.signOut(); clearPersistedAnswerThread(); setSession(null); setStatus("signed_out"); setError(null); setNotice(null); - }, [client]); + }, [client, invalidateAuthRequests]); const markSessionExpired = useCallback(() => { + invalidateAuthRequests(); clearPersistedAnswerThread(); setSession(null); setStatus("expired"); setNotice(null); setError("Your session expired. Sign in again to use private documents."); - }, []); + }, [invalidateAuthRequests]); const accessToken = session?.access_token ?? null; const authorizationHeader = useMemo(() => authorizationHeadersForAccessToken(accessToken), [accessToken]); + useEffect(() => { + const fingerprint = `${status}:${session?.user.id ?? "anonymous"}:${accessToken ?? "no-token"}`; + if (authFingerprintRef.current === null) { + authFingerprintRef.current = fingerprint; + return; + } + if (authFingerprintRef.current === fingerprint) return; + authFingerprintRef.current = fingerprint; + invalidateAuthRequests(); + }, [accessToken, invalidateAuthRequests, session?.user.id, status]); + const value = useMemo( () => ({ client, @@ -275,6 +303,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { notice, isConfigured: Boolean(client), authorizationHeader, + authEpoch, + registerAuthRequest, + isAuthEpochCurrent, signInWithEmail, signInWithPassword, signUpWithPassword, @@ -289,6 +320,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { error, notice, authorizationHeader, + authEpoch, + registerAuthRequest, + isAuthEpochCurrent, signInWithEmail, signInWithPassword, signUpWithPassword, diff --git a/src/lib/ward-output.ts b/src/lib/ward-output.ts index 050ee7b78..5bdcfa80e 100644 --- a/src/lib/ward-output.ts +++ b/src/lib/ward-output.ts @@ -635,6 +635,36 @@ function clinicalTableToTextRows(table: ClinicalThresholdTable) { ].filter(Boolean); } +export function formatDisplayedVisualEvidenceForClipboard(cards: VisualEvidenceCard[]) { + return cards.flatMap((card) => { + const explicitRows = card.tableRows?.length ? card.tableRows : null; + const rawRows = explicitRows ?? parseMarkdownTable(card.accessibleTableMarkdown ?? card.tableTextSnippet); + if (!rawRows?.length) return []; + const normalized = normalizeAccessibleTable(rawRows, explicitRows ? card.tableColumns : null, { + conservativeClinical: true, + }); + if (!normalized) return []; + const title = clinicalTableCaption(card.tableTitle || card.caption) || "Clinical table"; + const source = `Source: ${card.viewer_href}${card.page_number ? ` (page ${card.page_number})` : ""}`; + if (normalized.lowConfidence) { + return [ + "", + title, + "Table structure could not be confidently reconstructed. Review the original source image before use.", + source, + ]; + } + return [ + "", + title, + normalized.header.length ? `| ${normalized.header.join(" | ")} |` : "", + normalized.header.length ? `| ${normalized.header.map(() => "---").join(" | ")} |` : "", + ...normalized.body.slice(0, 6).map((row) => `| ${row.join(" | ")} |`), + source, + ].filter(Boolean); + }); +} + export function buildClinicalOutputSections(answer: RagAnswer | null | undefined) { if (!answer) return []; diff --git a/tests/answer-lifecycle.test.ts b/tests/answer-lifecycle.test.ts new file mode 100644 index 000000000..329851d4b --- /dev/null +++ b/tests/answer-lifecycle.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { answerLifecycleReducer, initialAnswerLifecycle } from "../src/lib/answer-lifecycle"; +describe("answer lifecycle", () => { + it("keeps cancellation distinct from completion", () => { + const loading = answerLifecycleReducer(initialAnswerLifecycle, { type: "start", query: "lithium" }); + const streaming = answerLifecycleReducer(loading, { type: "stream" }); + expect(answerLifecycleReducer(streaming, { type: "cancel" })).toEqual({ status: "cancelled", query: "lithium" }); + expect(answerLifecycleReducer(streaming, { type: "complete" }).status).toBe("completed"); + }); +}); diff --git a/tests/answer-render-policy.test.ts b/tests/answer-render-policy.test.ts index cb36d2766..a0e041950 100644 --- a/tests/answer-render-policy.test.ts +++ b/tests/answer-render-policy.test.ts @@ -125,6 +125,24 @@ function answer(overrides: Partial = {}): RagAnswer { } describe("answer render policy", () => { + it("copies the displayed table values, units, and canonical provenance", () => { + const model = buildAnswerRenderModel( + answer({ + visualEvidence: [ + visual({ + tableTitle: "ANC action thresholds", + tableColumns: ["ANC", "Action"], + tableRows: [["0.49 ×10^9/L", "Stop and escalate"]], + }), + ], + }), + ); + expect(model.copyText).toContain("ANC action thresholds"); + expect(model.copyText).toContain("0.49 ×10^9/L"); + expect(model.copyText).toContain("Stop and escalate"); + expect(model.copyText).toContain("/documents/doc-1?page=4&chunk=chunk-1"); + }); + it("strips high-yield bold markers from the copy/paste clinical draft", () => { const model = buildAnswerRenderModel( answer({ diff --git a/tests/api-client-error.test.ts b/tests/api-client-error.test.ts new file mode 100644 index 000000000..beab7d71c --- /dev/null +++ b/tests/api-client-error.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { parseApiErrorResponse } from "../src/lib/api-client-error"; + +describe("parseApiErrorResponse", () => { + it.each([401, 403])("does not retry protected status %s", async (status) => { + const error = await parseApiErrorResponse( + new Response(JSON.stringify({ message: "Sign in", code: "auth" }), { + status, + headers: { "content-type": "application/json" }, + }), + ); + expect(error).toMatchObject({ status, code: "auth", message: "Sign in", retryable: false }); + }); + it("preserves a 429 message and Retry-After delay", async () => { + const error = await parseApiErrorResponse( + new Response(JSON.stringify({ error: "Wait before retrying", code: "rate_limited" }), { + status: 429, + headers: { "content-type": "application/json", "retry-after": "7" }, + }), + ); + expect(error).toMatchObject({ status: 429, code: "rate_limited", retryable: true, retryAfterMs: 7000 }); + }); + it("safely parses an SSE error fallback", async () => { + const response = new Response( + 'event: error\ndata: {"message":"Unavailable","status":503,"details":{"code":"provider_unavailable"}}\n\n', + { status: 503, headers: { "content-type": "text/event-stream" } }, + ); + await expect(parseApiErrorResponse(response)).resolves.toMatchObject({ + status: 503, + code: "provider_unavailable", + message: "Unavailable", + retryable: true, + }); + }); +}); diff --git a/tests/auth-request-lifecycle.test.ts b/tests/auth-request-lifecycle.test.ts new file mode 100644 index 000000000..c0efa5efb --- /dev/null +++ b/tests/auth-request-lifecycle.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createAuthRequestLifecycle } from "../src/lib/auth-request-lifecycle"; + +describe("auth request lifecycle", () => { + it("aborts registered work and rejects commits from the previous epoch", () => { + const lifecycle = createAuthRequestLifecycle(4); + const controller = new AbortController(); + const abort = vi.spyOn(controller, "abort"); + const registration = lifecycle.register(controller); + + expect(registration.epoch).toBe(4); + expect(lifecycle.isCurrent(registration.epoch)).toBe(true); + + expect(lifecycle.invalidate()).toBe(5); + expect(abort).toHaveBeenCalledOnce(); + expect(lifecycle.isCurrent(registration.epoch)).toBe(false); + }); + + it("does not abort released work", () => { + const lifecycle = createAuthRequestLifecycle(); + const controller = new AbortController(); + const abort = vi.spyOn(controller, "abort"); + lifecycle.register(controller).release(); + + lifecycle.invalidate(); + + expect(abort).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index d2c64e288..893bb5162 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -22,7 +22,7 @@ const scannedFiles = [ }); const globalSearchShellSource = readFileSync( - resolve(process.cwd(), "src/components/clinical-dashboard/global-mockup-search-shell.tsx"), + resolve(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), "utf8", ); const clinicalDashboardSource = readFileSync(resolve(process.cwd(), "src/components/ClinicalDashboard.tsx"), "utf8"); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index c759934d5..bdfe7ebaf 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -3083,7 +3083,7 @@ describe("private document API access", () => { const limited = await answerRoute.POST(anonymousAnswerRequest()); expect(limited.status).toBe(429); expect(limited.headers.get("Retry-After")).toBe("60"); - expect(await payload(limited)).toEqual({ + expect(await payload(limited)).toMatchObject({ error: "Too many answer requests. Retry shortly.", retryAfterSeconds: 60, }); @@ -3137,7 +3137,7 @@ describe("private document API access", () => { const limited = await answerRoute.POST(answerRequest()); expect(limited.status).toBe(429); expect(limited.headers.get("Retry-After")).toBe("60"); - expect(await payload(limited)).toEqual({ + expect(await payload(limited)).toMatchObject({ error: "Too many answer requests. Retry shortly.", retryAfterSeconds: 60, }); @@ -3199,7 +3199,7 @@ describe("private document API access", () => { const limited = await searchRoute.POST(searchRequest()); expect(limited.status).toBe(429); expect(limited.headers.get("Retry-After")).toBe("60"); - expect(await payload(limited)).toEqual({ + expect(await payload(limited)).toMatchObject({ error: "Search is temporarily rate limited because too many requests were received. Retry shortly.", retryAfterSeconds: 60, }); @@ -3610,9 +3610,11 @@ describe("private document API access", () => { expect(response.status).toBe(429); expect(response.headers.get("Retry-After")).toBe("60"); - expect(body).toContain("event: error"); - expect(body).toContain('"status":429'); - expect(body).toContain("Too many answer requests. Retry shortly."); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(JSON.parse(body)).toMatchObject({ + error: "Too many answer requests. Retry shortly.", + details: { retryAfterSeconds: 60 }, + }); expect(answerQuestionWithScope).not.toHaveBeenCalled(); }); diff --git a/tests/private-search-scope.test.ts b/tests/private-search-scope.test.ts new file mode 100644 index 000000000..f53cdd021 --- /dev/null +++ b/tests/private-search-scope.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { persistPrivateSearchScope, restorePrivateSearchScope } from "../src/lib/private-search-scope"; + +const documentId = "11111111-1111-4111-8111-111111111111"; +const scopeRef = "22222222-2222-4222-8222-222222222222"; +function memoryStorage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + removeItem: (key: string) => void values.delete(key), + }; +} + +describe("private search scope", () => { + it("restores validated document IDs for the same owner and session", () => { + const storage = memoryStorage(); + expect(persistPrivateSearchScope(storage, "owner-a", [documentId], 100, () => scopeRef)).toBe(scopeRef); + expect(restorePrivateSearchScope(storage, scopeRef, "owner-a", 200)).toEqual({ + kind: "restored", + documentIds: [documentId], + }); + }); + it("fails closed for missing, expired, malformed, and wrong-owner state", () => { + const storage = memoryStorage(); + expect(restorePrivateSearchScope(storage, scopeRef, "owner-a", 100).kind).toBe("unavailable"); + persistPrivateSearchScope(storage, "owner-a", [documentId], 100, () => scopeRef); + expect(restorePrivateSearchScope(storage, scopeRef, "owner-b", 200)).toEqual({ + kind: "unavailable", + reason: "wrong_owner", + }); + expect(restorePrivateSearchScope(storage, scopeRef, "owner-a", 30 * 60 * 1000 + 101)).toEqual({ + kind: "unavailable", + reason: "expired", + }); + expect(restorePrivateSearchScope(storage, "not-a-ref", "owner-a", 200)).toEqual({ + kind: "unavailable", + reason: "invalid", + }); + }); +}); diff --git a/tests/production-mockup-boundary.test.ts b/tests/production-mockup-boundary.test.ts new file mode 100644 index 000000000..4ca870a15 --- /dev/null +++ b/tests/production-mockup-boundary.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("production and mockup boundaries", () => { + it("keeps production Documents routes free of fixture imports", () => { + for (const file of ["search/page.tsx", "source/page.tsx", "source/evidence/page.tsx"]) { + const source = readFileSync(resolve(process.cwd(), "src/app/documents", file), "utf8"); + expect(source).not.toContain("master-document-flow-mockups"); + } + }); + it("keeps the production shell implementation under its production name", () => { + const production = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + const compatibility = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/global-mockup-search-shell.tsx"), + "utf8", + ); + expect(production).toContain("export function GlobalSearchShell"); + expect(compatibility).toContain("GlobalSearchShell as GlobalMockupSearchShell"); + }); +}); diff --git a/tests/search-navigation-context.test.ts b/tests/search-navigation-context.test.ts index 1b4280b38..1d428fe06 100644 --- a/tests/search-navigation-context.test.ts +++ b/tests/search-navigation-context.test.ts @@ -109,4 +109,14 @@ describe("search navigation context", () => { ).toBe(true); expect(routedSubmissionContextChanged("answer:lithium", "answer", "lithium", {})).toBe(false); }); + + it("round-trips only a validated opaque private scope reference", () => { + const scopeRef = "22222222-2222-4222-8222-222222222222"; + const params = appendSearchNavigationContext(new URLSearchParams(), { scopeRef }); + expect(params.get("scopeRef")).toBe(scopeRef); + expect(readSearchNavigationContext(params).scopeRef).toBe(scopeRef); + expect( + readSearchNavigationContext(new URLSearchParams("scopeRef=private-document-title")).scopeRef, + ).toBeUndefined(); + }); }); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index cab9cb238..185421a70 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -75,7 +75,7 @@ test.describe("Clinical KB accessibility media smoke", () => { test("dashboard remains usable with reduced motion", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); - await page.setViewportSize({ width: 390, height: 820 }); + await page.setViewportSize({ width: 390, height: 844 }); await mockMinimalDashboardApi(page); await gotoApp(page); @@ -86,7 +86,7 @@ test.describe("Clinical KB accessibility media smoke", () => { test("dashboard remains usable with forced colors", async ({ page }) => { await page.emulateMedia({ forcedColors: "active" }); - await page.setViewportSize({ width: 390, height: 820 }); + await page.setViewportSize({ width: 390, height: 844 }); await mockMinimalDashboardApi(page); await gotoApp(page); @@ -94,4 +94,16 @@ test.describe("Clinical KB accessibility media smoke", () => { await openScopeControl(page); await expectNoPageHorizontalOverflow(page); }); + + test("dashboard remains usable at 200 percent zoom", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await mockMinimalDashboardApi(page); + await gotoApp(page); + await page.evaluate(() => { + document.documentElement.style.zoom = "2"; + }); + + await expectDashboardUsable(page); + await expectNoPageHorizontalOverflow(page); + }); }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 5f1c8ff20..f3720027a 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1502,6 +1502,26 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); + test("stopping generation removes provisional output and exposes a stable rerun action", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockDemoApi(page, { answerDelayMs: 1500 }); + const question = "What monitoring is required for clozapine?"; + await page.goto(`/?mode=answer&q=${encodeURIComponent(question)}&run=1`, { waitUntil: "domcontentloaded" }); + + const stop = page.getByTestId("stop-answer"); + await expect(stop).toBeVisible(); + await stop.focus(); + await page.keyboard.press("Enter"); + + const cancelled = page.getByTestId("answer-cancelled"); + await expect(cancelled).toContainText("Generation stopped"); + await expect(cancelled.getByRole("button", { name: "Run again" })).toBeVisible(); + await expect(page.getByTestId("plain-answer-response")).toHaveCount(0); + await expect(page.getByTestId("answer-streaming")).toHaveCount(0); + await page.waitForTimeout(1700); + await expect(page.getByTestId("plain-answer-response")).toHaveCount(0); + }); + test("answer results surface cross-mode quick links", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); @@ -1773,9 +1793,9 @@ test.describe("Clinical KB UI smoke coverage", () => { } for (const viewport of [ - { name: "390px mobile", width: 390, height: 820, expands: true }, + { name: "390px mobile", width: 390, height: 844, expands: true }, { name: "768px tablet", width: 768, height: 1024, expands: true }, - { name: "1440px desktop", width: 1440, height: 900, expands: false }, + { name: "1280px desktop", width: 1280, height: 800, expands: false }, ] as const) { test(`clinical table mobile expansion at ${viewport.name}`, async ({ page }) => { await page.setViewportSize({ width: viewport.width, height: viewport.height }); @@ -2157,12 +2177,13 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.getByRole("button", { name: "Find matching documents" }).click(); await expect(page).toHaveURL(/\/documents\/search\?.*q=lithium\+monitoring/); - await expect(page.getByRole("heading", { name: "Find source evidence" })).toBeVisible(); - const documentResults = page.getByRole("region", { name: "Document results" }); - await expect(documentResults).toContainText("Synthetic lithium monitoring protocol"); + const documentResults = page.getByRole("article").filter({ hasText: "Synthetic Lithium Monitoring Protocol" }); + await expect(documentResults).toBeVisible(); await expect(documentResults).toContainText("Best match"); - await expect(documentResults).toContainText("Tables 1"); - const openDocumentLink = documentResults.getByRole("link", { name: "Open document" }).first(); + await expect(documentResults).toContainText("1 table"); + const openDocumentLink = documentResults + .getByRole("link", { name: /Open Synthetic lithium monitoring protocol/i }) + .last(); await expect(openDocumentLink).toBeVisible(); // Exact viewer target built from mockDemoApi's lithium result (document_id / bestPages[0] / // bestChunkIds[0]): a link to the wrong document, page, or chunk must fail this assertion. @@ -2170,7 +2191,7 @@ test.describe("Clinical KB UI smoke coverage", () => { "href", "/documents/11111111-1111-4111-8111-111111111111?page=1&chunk=44444444-4444-4444-8444-444444444442", ); - await expect(page.getByRole("complementary").filter({ hasText: "Selected source" })).toHaveCount(0); + await expect(page.getByRole("complementary", { name: "Selected document evidence" })).toBeVisible(); await expectNoPageHorizontalOverflow(page); }); @@ -2207,7 +2228,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.getByRole("button", { name: "Find matching documents" }).click(); await expect(page).toHaveURL(/\/documents\/search\?/); await expect(page.locator("body")).not.toContainText(/failed to fetch|Search failed/i); - await expect(page.getByRole("heading", { name: /Search command centre|Find source evidence/ })).toBeVisible(); + await expect(page.getByRole("heading", { name: "No matching documents" }).first()).toBeVisible(); const demoDocId = "11111111-1111-4111-8111-111111111111"; await gotoApp(page, `/documents/${demoDocId}?chunk=55555555-5555-4555-8555-555555555555`); diff --git a/tests/upload-outcome.test.ts b/tests/upload-outcome.test.ts new file mode 100644 index 000000000..3f2c160f6 --- /dev/null +++ b/tests/upload-outcome.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { uploadOutcomeFromResponse } from "../src/components/clinical-dashboard/DocumentManagerPanel"; + +describe("upload outcomes", () => { + it("distinguishes queued, duplicate, and failed server responses", () => { + expect(uploadOutcomeFromResponse("fresh.pdf", 201, { document: { id: "doc-1" }, job: { id: "job-1" } })).toEqual({ + kind: "queued", + fileName: "fresh.pdf", + documentId: "doc-1", + jobId: "job-1", + }); + expect( + uploadOutcomeFromResponse("copy.pdf", 200, { + duplicate: true, + document: { id: "doc-1" }, + message: "Already exists; no duplicate job was queued.", + }), + ).toEqual({ + kind: "duplicate", + fileName: "copy.pdf", + documentId: "doc-1", + message: "Already exists; no duplicate job was queued.", + }); + expect( + uploadOutcomeFromResponse("large.pdf", 413, { + code: "payload_too_large", + message: "File exceeds 150 MB upload limit.", + }), + ).toEqual({ + kind: "failed", + fileName: "large.pdf", + status: 413, + code: "payload_too_large", + message: "File exceeds 150 MB upload limit.", + }); + }); +}); diff --git a/tests/upload-size-contract.test.ts b/tests/upload-size-contract.test.ts new file mode 100644 index 000000000..6e82a9cb9 --- /dev/null +++ b/tests/upload-size-contract.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { assertAllowedFile, PublicApiError } from "../src/lib/http"; + +describe("upload size contract", () => { + it("uses 413 and a stable code one byte above the configured limit", () => { + const file = new File([new Uint8Array(1024 * 1024 + 1)], "large.pdf", { type: "application/pdf" }); + expect(() => assertAllowedFile(file, 1)).toThrowError( + expect.objectContaining>({ status: 413, details: { code: "payload_too_large" } }), + ); + }); +}); diff --git a/tests/verify-pr-local.test.ts b/tests/verify-pr-local.test.ts new file mode 100644 index 000000000..eef8010ef --- /dev/null +++ b/tests/verify-pr-local.test.ts @@ -0,0 +1,41 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const script = path.resolve("scripts/verify-pr-local.mjs"); + +function dryRun(files: string, ...args: string[]) { + return execFileSync(process.execPath, [script, "--dry-run", "--files", files, ...args], { + encoding: "utf8", + }); +} + +describe("verify-pr-local CLI", () => { + it("uses the explicit file list and does not execute checks in dry-run mode", () => { + const output = dryRun("docs/frontend-architecture.md"); + + expect(output).toContain("Changed files: docs/frontend-architecture.md"); + expect(output).toContain("PR-local verification plan (dry run)"); + expect(output).toContain("- npm run check:runtime"); + expect(output).toContain("- build skipped"); + expect(output).not.toContain("\n> npm run "); + }); + + it("selects build, offline RAG, and extended UI checks for affected source", () => { + const output = dryRun("src/app/api/answer/stream/route.ts", "--extended"); + + expect(output).toContain("- npm run build"); + expect(output).toContain("- npm run eval:rag:offline"); + expect(output).toContain("- npm run verify:ui"); + }); + + it("requires explicit approval before executing the extended plan", () => { + const result = spawnSync(process.execPath, [script, "--extended", "--files", "docs/frontend-architecture.md"], { + encoding: "utf8", + env: { ...process.env, ALLOW_EXTENDED_PR_LOCAL: "" }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("ALLOW_EXTENDED_PR_LOCAL=true"); + }); +});