From d2a90f082d2f696a3c903e08544ae156bc93de41 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:05:50 +0800 Subject: [PATCH 1/5] fix: harden document workflows and auth-bound UI state --- docs/codebase-index.md | 18 +- docs/frontend-architecture.md | 22 + docs/site-map.md | 31 +- scripts/generate-site-map.ts | 32 +- scripts/verify-pr-local.mjs | 79 ++- src/app/api/answer/stream/route.ts | 17 +- src/app/documents/search/page.tsx | 15 +- src/app/documents/source/evidence/page.tsx | 18 +- src/app/documents/source/page.tsx | 30 +- .../mockups/document-search/search/page.tsx | 12 + .../mockups/document-search/source/page.tsx | 12 + src/components/ClinicalDashboard.tsx | 299 +++++++-- src/components/DocumentManagementActions.tsx | 20 +- src/components/DocumentViewer.tsx | 37 +- .../DocumentManagerPanel.tsx | 146 ++++- .../global-mockup-search-shell.tsx | 605 +----------------- .../global-search-shell.tsx | 601 ++++++++++++++++- .../clinical-dashboard/search-utils.ts | 20 +- src/components/document-search-mockups.tsx | 4 +- .../master-document-flow-mockups.tsx | 12 +- src/components/mode-home-template.tsx | 10 +- src/lib/answer-lifecycle.ts | 30 + src/lib/answer-render-policy.ts | 7 +- src/lib/api-client-error.ts | 75 +++ src/lib/api-rate-limit.ts | 3 + src/lib/auth-request-lifecycle.ts | 40 ++ src/lib/document-flow-routes.ts | 15 + src/lib/http.ts | 2 +- src/lib/private-search-scope.ts | 67 ++ src/lib/search-navigation-context.ts | 14 +- src/lib/supabase/client.tsx | 40 +- src/lib/ward-output.ts | 30 + tests/answer-lifecycle.test.ts | 10 + tests/answer-render-policy.test.ts | 18 + tests/api-client-error.test.ts | 35 + tests/auth-request-lifecycle.test.ts | 30 + ...clinical-dashboard-merge-artifacts.test.ts | 2 +- tests/private-access-routes.test.ts | 14 +- tests/private-search-scope.test.ts | 41 ++ tests/production-mockup-boundary.test.ts | 24 + tests/search-navigation-context.test.ts | 10 + tests/ui-accessibility.spec.ts | 16 +- tests/ui-smoke.spec.ts | 39 +- tests/upload-outcome.test.ts | 37 ++ tests/upload-size-contract.test.ts | 11 + tests/verify-pr-local.test.ts | 41 ++ 46 files changed, 1874 insertions(+), 817 deletions(-) create mode 100644 docs/frontend-architecture.md create mode 100644 src/app/mockups/document-search/search/page.tsx create mode 100644 src/app/mockups/document-search/source/page.tsx create mode 100644 src/lib/answer-lifecycle.ts create mode 100644 src/lib/api-client-error.ts create mode 100644 src/lib/auth-request-lifecycle.ts create mode 100644 src/lib/private-search-scope.ts create mode 100644 tests/answer-lifecycle.test.ts create mode 100644 tests/api-client-error.test.ts create mode 100644 tests/auth-request-lifecycle.test.ts create mode 100644 tests/private-search-scope.test.ts create mode 100644 tests/production-mockup-boundary.test.ts create mode 100644 tests/upload-outcome.test.ts create mode 100644 tests/upload-size-contract.test.ts create mode 100644 tests/verify-pr-local.test.ts diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 4bbbda55a..a98ae225f 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 @@ -284,14 +284,14 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: ## Related docs -| Topic | Doc | -| ----------------------- | --------------------------------------------- | -| Routes and modes | `docs/site-map.md` | -| Search/RAG roadmap | `docs/search-rag-master-plan.md` | -| Reindex operations | `docs/reindex-runbook.md` | -| Production readiness | `docs/production-readiness-checklist.md` | -| Frontend refactor | `docs/frontend-architecture-refactor-plan.md` | -| Repo audit (2026-07-01) | `docs/audit/repo-audit-2026-07-01.md` | +| Topic | Doc | +| ----------------------- | ---------------------------------------- | +| Routes and modes | `docs/site-map.md` | +| Search/RAG roadmap | `docs/search-rag-master-plan.md` | +| Reindex operations | `docs/reindex-runbook.md` | +| Production readiness | `docs/production-readiness-checklist.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 6012d1938..cd2299b55 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`. @@ -32,23 +32,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 @@ -512,6 +512,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`. @@ -578,6 +580,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 b0635e586..231f0dd97 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"; @@ -163,6 +163,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"; @@ -696,7 +699,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); @@ -752,6 +755,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 @@ -871,6 +877,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 @@ -922,15 +929,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]); @@ -1098,10 +1177,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(); @@ -1117,6 +1200,7 @@ export function ClinicalDashboard({ setApiUnavailable(false); const localIdentity = await readLocalProjectIdentity().catch(() => null); + if (!canCommit()) return; if (!localIdentity?.localServer?.safeLocalOrigin) { setLocalProjectReady(false); setApiUnavailable(true); @@ -1133,7 +1217,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()) { @@ -1186,17 +1274,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 || @@ -1270,17 +1362,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 () => { @@ -1291,7 +1392,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 }, ); @@ -1315,6 +1416,7 @@ export function ClinicalDashboard({ } }, [ authorizationHeader, + authBoundFetch, canUsePrivateApis, clientDemoMode, documentsPagination, @@ -1326,7 +1428,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, }); @@ -1344,6 +1446,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.", @@ -1352,14 +1455,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, @@ -1387,6 +1490,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.", @@ -1395,7 +1499,7 @@ export function ClinicalDashboard({ setIndexingActionId(null); } }, - [authorizationHeader, markSessionExpired, refresh], + [authBoundFetch, authorizationHeader, markSessionExpired, refresh], ); const enrichDocument = useCallback( (documentId: string) => reindexDocument(documentId, "enrichment"), @@ -1464,7 +1568,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", @@ -1491,12 +1595,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, @@ -1772,9 +1878,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); @@ -1828,9 +1932,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; @@ -1838,8 +1940,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) { @@ -1857,6 +1965,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) { @@ -1868,7 +1977,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; @@ -1894,7 +2005,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) { @@ -1954,11 +2071,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 @@ -2025,13 +2148,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); @@ -2048,6 +2177,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; @@ -2097,6 +2227,7 @@ export function ClinicalDashboard({ abortController.signal, ), onProgress, + abortController.signal, ) : await runWithRetries( () => @@ -2109,6 +2240,7 @@ export function ClinicalDashboard({ answerWatchdog.touch, ), onProgress, + abortController.signal, ); if (!resultUsable(payload)) { @@ -2141,10 +2273,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 @@ -2152,6 +2285,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. @@ -2167,6 +2301,7 @@ export function ClinicalDashboard({ run: true, queryMode: queryModeOverride, scopeFilters: filtersOverride, + scopeRef: privateScopeRef, }), ); if (isAnswerFollowUp) { @@ -2178,16 +2313,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); } @@ -2215,6 +2352,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( @@ -2224,6 +2366,7 @@ export function ClinicalDashboard({ run: true, queryMode: effectiveQueryMode, scopeFilters: effectiveScopeFilters, + scopeRef: privateScopeRef, }), ); return; @@ -2232,7 +2375,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; @@ -2242,6 +2392,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); @@ -2276,6 +2427,7 @@ export function ClinicalDashboard({ latestAnswerQuery, routedSearchContext, routedSearchContextSignature, + privateScopeStatus, ]); function pickRecentQuery(recentQuery: string) { @@ -2509,7 +2661,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", @@ -2528,6 +2680,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); @@ -2544,7 +2697,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", @@ -2561,6 +2714,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); @@ -3169,6 +3323,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 28a40eafd..07d4f51f5 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2015,7 +2015,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(); @@ -2113,6 +2120,7 @@ export function DocumentViewer({ } const controller = new AbortController(); + const authRequest = registerAuthRequest(controller); const reset = window.setTimeout(() => { if (!controller.signal.aborted) { setLoadingDocument(true); @@ -2133,6 +2141,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)); @@ -2176,7 +2187,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; @@ -2238,7 +2249,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(() => { @@ -2248,6 +2259,7 @@ export function DocumentViewer({ return () => { window.clearTimeout(reset); controller.abort(); + authRequest.release(); }; }, [ authStatus, @@ -2260,6 +2272,8 @@ export function DocumentViewer({ initialPage, isConfigured, markSessionExpired, + registerAuthRequest, + isAuthEpochCurrent, previewAttempt, ]); @@ -2275,6 +2289,7 @@ export function DocumentViewer({ } const controller = new AbortController(); + const authRequest = registerAuthRequest(controller); const timeout = window.setTimeout(() => { setSearchingDocument(true); setDocumentSearchError(null); @@ -2289,12 +2304,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."); }) @@ -2306,8 +2321,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 4bbaae83d..1832578d5 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); @@ -329,7 +419,11 @@ export function UploadPanel({
)} {(displayStatus || demoMode) && ( -

+

{displayStatus ?? demoUploadReadOnlyMessage}

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

{documentTitle}

@@ -428,7 +525,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 6b7b82230..b7238f69a 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/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index d727696a5..384916dec 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -90,15 +90,15 @@ export function ModeHomeHero({ {title}

@@ -245,10 +245,10 @@ export function ModeHomeTemplate({

- {title} - -

-======= className="text-balance text-hero font-extrabold leading-[1.05] tracking-normal text-[color:var(--text-heading)]" > {title}

->>>>>>> origin/main {subtitle}

@@ -246,11 +230,7 @@ export function ModeHomeTemplate({
From 5046888460050477014197dcfab36c4dbc1213a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:19:10 +0000 Subject: [PATCH 4/5] fix: resolve merge conflicts in answer-render-policy and tests --- src/lib/answer-render-policy.ts | 6 +----- tests/answer-render-policy.test.ts | 5 ++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts index 09375e9ff..485e0ae2e 100644 --- a/src/lib/answer-render-policy.ts +++ b/src/lib/answer-render-policy.ts @@ -604,11 +604,7 @@ export function buildAnswerRenderModel( relatedDocuments, bestSource, warnings, -<<<<<<< HEAD - copyText: formatAnswerRenderCopyText({ answerText, trust, primarySources, warnings, visualEvidence }), -======= - copyText: formatAnswerRenderCopyText({ answerText: copyAnswerText, trust, primarySources, warnings }), ->>>>>>> origin/main + copyText: formatAnswerRenderCopyText({ answerText: copyAnswerText, trust, primarySources, warnings, visualEvidence }), debugReasons: options.includeDebugReasons ? decisions : undefined, }; } diff --git a/tests/answer-render-policy.test.ts b/tests/answer-render-policy.test.ts index 3cd32dbf2..a0e041950 100644 --- a/tests/answer-render-policy.test.ts +++ b/tests/answer-render-policy.test.ts @@ -125,7 +125,6 @@ function answer(overrides: Partial = {}): RagAnswer { } describe("answer render policy", () => { -<<<<<<< HEAD it("copies the displayed table values, units, and canonical provenance", () => { const model = buildAnswerRenderModel( answer({ @@ -142,7 +141,8 @@ describe("answer render policy", () => { 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({ @@ -153,7 +153,6 @@ describe("answer render policy", () => { expect(model.copyText).not.toContain("**"); expect(model.copyText).toContain("withhold clozapine"); expect(model.copyText).toContain("within 24 hours"); ->>>>>>> origin/main }); it("limits unsupported answers to source review and warnings even when raw extras are present", () => { From 7ea33b20d230f65eb5ac5f6a7386ebd0db92a6ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:24:34 +0000 Subject: [PATCH 5/5] Fix Prettier formatting in answer render policy --- src/lib/answer-render-policy.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts index 485e0ae2e..461ec358e 100644 --- a/src/lib/answer-render-policy.ts +++ b/src/lib/answer-render-policy.ts @@ -604,7 +604,13 @@ export function buildAnswerRenderModel( relatedDocuments, bestSource, warnings, - copyText: formatAnswerRenderCopyText({ answerText: copyAnswerText, trust, primarySources, warnings, visualEvidence }), + copyText: formatAnswerRenderCopyText({ + answerText: copyAnswerText, + trust, + primarySources, + warnings, + visualEvidence, + }), debugReasons: options.includeDebugReasons ? decisions : undefined, }; }