diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea611ba43..9f0a04c6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,12 +24,20 @@ jobs: node-version-file: ".nvmrc" cache: npm + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Install dependencies run: npm ci - name: Runtime alignment run: npm run check:runtime + - name: Edge function typecheck + run: npm run check:edge:functions + - name: Production readiness (CI-safe) run: npm run check:production-readiness:ci diff --git a/.gitignore b/.gitignore index 79da87388..c66b66fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ dev-server*.log # generated sample uploads /sample-documents/ +/tmp/ # python __pycache__/ diff --git a/README.md b/README.md index 9d654017d..1fc0834f1 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ questions with source citations that link back to the original PDF/document. ## Setup -1. Use Node.js 24.x. CI runs on Node 24, and `.nvmrc` / `.node-version` - pin the same runtime for local version managers. +1. Use Node.js 24.x with npm 11.x. CI runs on Node 24, and `.nvmrc` / + `.node-version` pin the same runtime for local version managers. CI also runs `npm run check:edge:functions`, which requires Deno v2.x. 2. Copy `.env.example` to `.env.local` and fill in Supabase and OpenAI values. 3. Confirm the Supabase target: @@ -30,13 +30,17 @@ demo mode if that stale ref appears in `.env.local`. 4. Run `supabase/schema.sql` in the `Clinical KB Database` Supabase project SQL editor. -5. Install optional PDF/OCR worker dependencies: +5. Install Deno v2.x to run Edge Function type checks (`npm run check:edge:functions`). + CI installs Deno automatically via `denoland/setup-deno`. For local use, follow the + [Deno installation guide](https://docs.deno.com/runtime/getting_started/installation/) + and ensure `deno --version` reports a 2.x release. +6. Install optional PDF/OCR worker dependencies: ```bash python -m pip install -r worker/python/requirements.txt ``` -6. Start the app: +7. Start the app: ```bash npm run dev diff --git a/docs/process-hardening.md b/docs/process-hardening.md index b1ee87cb4..b6742a82f 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -33,8 +33,9 @@ This document turns the current process review into phased, durable repo practic ## Phase 4 - Release maturity -- `npm run check:runtime` is the strict release runtime gate and is now part of `npm run verify:release`; it fails outside Node 24.x. -- CI runs `npm run check:runtime` after dependency install so branch verification cannot silently drift to Node 25+. +- `npm run check:runtime` is the strict runtime gate and is now part of `npm run verify:cheap`, `npm run verify:ui`, and `npm run verify:release`; it fails outside Node 24.x or npm 11.x when run through npm. +- CI runs `npm run check:runtime` after dependency install so branch verification cannot silently drift away from Node 24. +- `npm run check:edge:functions` is the Deno type gate for the Supabase `indexing-v3-agent` Edge Function. - Decide whether CI should run all Playwright browser projects on protected branches, release branches, or a scheduled workflow instead of every push. - Add explicit review ownership for clinical source governance, outdated-source handling, incident review, and decommission decisions. - Record production-readiness outcomes in release notes whenever clinical workflow, source governance, privacy, or deployment assumptions change. diff --git a/docs/production-readiness-checklist.md b/docs/production-readiness-checklist.md index dc9cda358..d95d6dc03 100644 --- a/docs/production-readiness-checklist.md +++ b/docs/production-readiness-checklist.md @@ -3,7 +3,7 @@ This is the runbook to make the app publishable in one focused pass. - Branch: `codex/premium-redesign` (do not touch `.env` / secrets directly). -- Runtime target: Next.js 16.2.7, Node 24.x, npm >= 11. +- Runtime target: Next.js 16.2.9, Node 24.x, npm 11.x. - Supabase target: `sjrfecxgysukkwxsowpy` (`Clinical KB Database`). ## Immediate completion targets @@ -21,7 +21,7 @@ This is the runbook to make the app publishable in one focused pass. - used in CI and non-blocking on local-only secret absence. - [x] Added strict runtime release gate: - `npm run check:runtime` - - enforces Node 24.x before `npm run verify:release`. + - enforces Node 24.x and npm 11.x before broad local and release verification. ## Remaining high-priority publish items (same day) diff --git a/docs/project-alignment-cleanup.md b/docs/project-alignment-cleanup.md index 03581db57..f5d452301 100644 --- a/docs/project-alignment-cleanup.md +++ b/docs/project-alignment-cleanup.md @@ -18,7 +18,7 @@ - CI verifies the project on Node.js 24, so local development should also use Node.js 24.x. - `.nvmrc`, `.node-version`, and `package.json` `engines` all declare the Node 24 runtime expectation. -- New cleanup or dependency work should be verified on Node 24 before release, even when local shells happen to use newer Node versions. +- New cleanup or dependency work should be verified on Node 24 before release, even when local shells happen to use a different Node version. ## Stale branch audit diff --git a/docs/redesign/06-verification.md b/docs/redesign/06-verification.md index 4e7f7df15..01fa3376a 100644 --- a/docs/redesign/06-verification.md +++ b/docs/redesign/06-verification.md @@ -51,7 +51,7 @@ Unverified or limited: | Lint | `npm run lint` (eslint 9.39.4) | ✅ pass, no warnings | | Smoke (chromium) | `npx playwright test tests/ui-smoke.spec.ts --project=chromium` | ✅ **22/22 pass** | -Clean-install caveat resolved in the reconciliation branch: `eslint` was pinned back to the latest compatible 9.x range because `eslint-config-next@16.2.7` pulls `eslint-plugin-react@7.37.5`, whose peer range supports ESLint 9 but not ESLint 10. `npm ci` now completes and `npm run lint` passes under Node 22.22.3. +Clean-install caveat resolved in the reconciliation branch: `eslint` was pinned back to the latest compatible 9.x range because `eslint-config-next@16.2.9` pulls `eslint-plugin-react@7.37.5`, whose peer range supports ESLint 9 but not ESLint 10. The current runtime target is Node 24.x. ## 2. Smoke detail diff --git a/docs/supabase-migration-reconciliation.md b/docs/supabase-migration-reconciliation.md new file mode 100644 index 000000000..406d53d49 --- /dev/null +++ b/docs/supabase-migration-reconciliation.md @@ -0,0 +1,31 @@ +# Supabase Migration Reconciliation + +Last reviewed: 2026-06-27 + +Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`) + +## Policy + +- Do not use `supabase db push` while local and remote migration history are divergent. +- Use `supabase migration repair --linked --status applied ` only when live database evidence proves the migration effect already exists. +- Leave all other local-only migrations unrepaired until their effects are verified or deliberately applied. + +## Verified Applied + +These versions are safe to mark applied in Supabase migration history: + +- `20260625033425` - `document_strict_gate_status` exists, `repair_strict_enrichment_gate_batch(integer)` exists, service role can read/execute, and anon cannot read/execute. +- `20260625033944` - `complete_strict_enrichment_job(uuid, uuid, text, text, text)` exists, service role can execute, and anon cannot execute. +- `20260626000000` - duplicate index `ingestion_job_stages_doc_idx` is absent and canonical index `ingestion_job_stages_document_started_idx` exists. + +## Skipped + +All other local-only migrations from `supabase migration list --linked` remain unrepaired until they are individually verified. This includes older search/retrieval/API-rate-limit migrations and the current `20260626020000` retrieval RPC performance migration. + +## Verification Commands + +```powershell +npx supabase migration list --linked +npx supabase db advisors --linked +npx supabase db query --linked "select to_regclass('public.document_strict_gate_status') as gate_view, to_regprocedure('public.repair_strict_enrichment_gate_batch(integer)') as repair_rpc, to_regprocedure('public.complete_strict_enrichment_job(uuid, uuid, text, text, text)') as complete_rpc, to_regclass('public.ingestion_job_stages_doc_idx') as duplicate_index, to_regclass('public.ingestion_job_stages_document_started_idx') as canonical_stage_index;" +``` diff --git a/mockups/README.md b/mockups/README.md index 694bd5ae6..7b96b9157 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -6,6 +6,9 @@ This folder collects the current mockup files for the Clinical KB Database proje - `favourites-hub/page.tsx` - copied from `src/app/mockups/favourites-hub/page.tsx` - `medication-prescribing/page.tsx` - copied from `src/app/mockups/medication-prescribing/page.tsx` +- `answer-evidence-popups/page.tsx` - copied from `src/app/mockups/answer-evidence-popups/page.tsx` +- `user-home-profile/page.tsx` - copied from `src/app/mockups/user-home-profile/page.tsx` +- `mode-dropdown` - runnable mockup only, in `src/app/mockups/mode-dropdown/page.tsx` ## App routes @@ -13,4 +16,6 @@ The runnable versions remain in the Next.js app route tree: - `/mockups/favourites-hub` - `/mockups/medication-prescribing` - +- `/mockups/answer-evidence-popups` +- `/mockups/user-home-profile` +- `/mockups/mode-dropdown` diff --git a/mockups/answer-evidence-popups/page.tsx b/mockups/answer-evidence-popups/page.tsx new file mode 100644 index 000000000..cacda1185 --- /dev/null +++ b/mockups/answer-evidence-popups/page.tsx @@ -0,0 +1,734 @@ +import { + AlertCircle, + BookOpen, + CheckCircle2, + ChevronDown, + Copy, + ExternalLink, + FileImage, + FileText, + Filter, + Layers, + ListChecks, + Maximize2, + Quote, + Search, + ShieldAlert, + Table2, + Target, + X, +} from "lucide-react"; +import type { ReactNode } from "react"; + +const sources = [ + ["Clozapine physical health protocol", "p.12 - current policy", "Direct", "92%"], + ["Clozapine monitoring table image evidence", "p.14 - locally reviewed", "Table", "86%"], + ["Shared-care communication checklist", "p.7 - review due", "Related", "71%"], +] as const; + +const tabs = [ + ["Tables", "1", ListChecks], + ["Sources", "3", Layers], + ["Images", "2", FileImage], + ["Quotes", "2", Quote], + ["PDFs", "2", FileText], + ["Map", "4", BookOpen], +] as const; + +const tableRows = [ + ["FBC/ANC", "Baseline, weekly initially, then per protocol", "Hold/escalate if ANC threshold breached"], + [ + "Myocarditis", + "Symptoms, pulse, troponin/CRP where locally required", + "Urgent review for fever, chest pain, dyspnoea", + ], + ["Metabolic", "Weight, waist, lipids, glucose/HbA1c", "Shared-care follow-up"], +] as const; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--clinical-chat-teal)]"; + +function Shell({ children }: { children: ReactNode }) { + return ( +
+
+
+
+
+
+ + + +

+ Clinical KB mockup +

+
+

+ Answer evidence popup states +

+

+ A polished set of evidence popups showing what clinicians see before opening source PDFs: source + previews, evidence tabs, review controls, table expansion, and weak-support warnings. +

+
+
+

+ Design priorities +

+
+ Verify source first + Fast source opening + Mobile sheet parity +
+
+
+
+ {children} +
+
+ ); +} + +function Section({ title, body, children }: { title: string; body: string; children: ReactNode }) { + return ( +
+
+
+

{title}

+

{body}

+
+
+ {children} +
+ ); +} + +function Pill({ children, tone = "neutral" }: { children: ReactNode; tone?: "neutral" | "info" | "success" | "warn" }) { + const toneClass = + tone === "success" + ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" + : tone === "warn" + ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" + : tone === "info" + ? "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]" + : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]"; + return ( + svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:shrink-0`} + > + {children} + + ); +} + +function Action({ children, primary = false }: { children: ReactNode; primary?: boolean }) { + const baseClass = `inline-flex min-h-10 max-w-full min-w-0 items-center justify-center gap-2 rounded-md px-3 text-center text-xs font-semibold leading-tight transition hover:-translate-y-px hover:shadow-[var(--shadow-tight)] active:translate-y-0 ${focusRing} [&>svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:shrink-0 sm:min-h-11 sm:text-sm`; + return ( + + ); +} + +function CloseButton({ label = "Close popup" }: { label?: string }) { + return ( + + ); +} + +function SourceCapsule() { + return ( + + ); +} + +function SourceRows() { + return ( +
+ {sources.map(([title, meta, support, score], index) => ( +
+ +
+ ))} +
+ ); +} + +function ActionRow({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ButtonText({ children }: { children: ReactNode }) { + return {children}; +} + +function SourcePreviewPopover() { + return ( +
+
+
+

Source preview

+

+ Check the best passage, status, and source action before opening the PDF. +

+
+ 3 sources +
+ +
+ “Monitor FBC/ANC, myocarditis symptoms, metabolic risk, constipation, and shared-care communication during + clozapine initiation.” +
+ + + + Open source + + + + Copy quote + + + + View cited section + + +
+ ); +} + +function MobileSheetFrame({ + title, + description, + children, +}: { + title: string; + description: string; + children: ReactNode; +}) { + return ( +
+
+
+
+

{title}

+

{description}

+
+ +
+
{children}
+
+ ); +} + +function EvidenceTabs({ selected }: { selected: string }) { + return ( +
+
+ {tabs.map(([label, count, Icon]) => { + const active = label === selected; + return ( + + ); + })} +
+
+ ); +} + +function FeedbackPanel() { + return ( +
+

Clinical verification

+

+ Mark whether the linked evidence is safe to use, needs correction, or is source-insufficient. +

+
+ + + Verified + + + + Needs correction + + + + Source insufficient + +
+
+ ); +} + +function EvidenceSummaryMini({ selected }: { selected: string }) { + return ( +
+
+
+

{selected} evidence

+

+ Showing only the items used to support this answer. +

+
+ Source-backed +
+
+ ); +} + +function TablePreview({ expanded = false }: { expanded?: boolean }) { + return ( +
+
+ Clozapine monitoring schedule + {expanded ? ( + Verify against source + ) : ( + + )} +
+
+ + + + {["Domain", "Monitoring", "Action"].map((header) => ( + + ))} + + + + {tableRows.map((row) => ( + + {row.map((cell) => ( + + ))} + + ))} + +
+ {header} +
+ {cell} +
+
+
+ ); +} + +function SourceCards() { + return ( +
+ {sources.map(([title, meta, support, score]) => ( +
+
+ +
+

{title}

+

{meta}

+
+ + {support} + {score} + +
+

+ Passage supports monitoring and escalation wording. Open source to inspect the highlighted PDF section. +

+ + + + Open source + + + + Scope to this + + +
+ ))} +
+ ); +} + +function QuoteCards() { + return ( +
+ {[1, 2].map((item) => ( +
+
+

Exact quote

+ {item === 1 ? "p.12" : "p.14"} +
+
+ “FBC/ANC monitoring, myocarditis symptoms, metabolic review and constipation prevention should be + checked during initiation and ongoing care.” +
+ + + + Copy + + + + Ask about quote + + +
+ ))} +
+ ); +} + +function ImageEvidence() { + const items = [ + { label: "Table crop", icon: Table2, body: "Monitoring domains extracted from a table image." }, + { label: "PDF page region", icon: FileImage, body: "Page crop used to check source layout and nearby wording." }, + ] as const; + + return ( +
+ {items.map(({ label, icon: Icon, body }) => ( +
+
+
+ + p.14 +
+
+
+

{label}

+

{body}

+
+
+ ))} +
+ ); +} + +function PdfLinks() { + return ( +
+ {["Clozapine physical health protocol", "Shared-care communication checklist"].map((title, index) => ( + + ))} +
+ ); +} + +function EvidenceMap() { + const rows = [ + ["Monitoring", "Moderate", "2", "Current / locally reviewed"], + ["Escalation", "Strong", "3", "Current / locally reviewed"], + ["Metabolic review", "Partial", "1", "Review due"], + ] as const; + return ( +
+ + + + {["Section", "Support", "Citations", "Source status"].map((header) => ( + + ))} + + + + {rows.map((row) => ( + + {row.map((cell) => ( + + ))} + + ))} + +
+ {header} +
+ {cell} +
+
+ ); +} + +function MobileEvidencePanel({ selected }: { selected: string }) { + return ( + +
+ {selected === "Tables" ? : } + + {selected === "Tables" ? : null} + {selected === "Sources" ? : null} + {selected === "Images" ? : null} + {selected === "Quotes" ? : null} + {selected === "PDFs" ? : null} + {selected === "Map" ? : null} +
+
+ ); +} + +function DesktopEvidenceDrawer() { + return ( +
+
+
+

Evidence review

+

+ Verify the answer against cited passages before using it clinically. +

+
+ + + Source-backed + +
+
+
+ + +
+
+
+

Pinned source

+

Clozapine physical health protocol

+
+ Current + Locally reviewed +
+
+ +
+
+
+ ); +} + +function TableDialog() { + return ( +
+
+
+

Clozapine monitoring table

+

+ Expanded from visual evidence. Use the source PDF for final verification. +

+
+ +
+
+ +
+
+ ); +} + +function WeakEvidencePopup() { + return ( +
+
+ + + +
+
+

Evidence support is limited

+ Do not copy into notes +
+

+ Treat this as source finding only. The closest indexed passages are nearby, but they do not directly support + a clinical answer yet. +

+ + + + Open closest passage + + + + Restrict to current local sources + + +
+
+
+ ); +} + +export default function AnswerEvidencePopupsMockupPage() { + return ( + +
+
+
+

+ Clozapine monitoring should include FBC/ANC, myocarditis symptoms, metabolic checks, constipation + prevention, and shared-care communication. +

+ +
+ +
+
+
+ +
+ + + +
+ +
+
+ {["Tables", "Sources", "Images", "Quotes", "PDFs", "Map"].map((tab) => ( +
+

+ {tab} tab +

+ +
+ ))} +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/mockups/user-home-profile/page.tsx b/mockups/user-home-profile/page.tsx new file mode 100644 index 000000000..48a94fa75 --- /dev/null +++ b/mockups/user-home-profile/page.tsx @@ -0,0 +1 @@ +export { default } from "../../src/app/mockups/user-home-profile/page"; diff --git a/package-lock.json b/package-lock.json index 68b8b1966..42319c860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@next/env": "^16.2.9", + "@next/env": "16.2.9", "@supabase/supabase-js": "^2.108.2", "exceljs": "^4.4.0", "jszip": "^3.10.1", @@ -29,7 +29,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.3.1", - "@types/node": "^24", + "@types/node": "^24.13.2", "@types/pdf-parse": "^1.1.5", "@types/pdfkit": "^0.17.6", "@types/react": "^19.2.17", @@ -2621,6 +2621,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", diff --git a/package.json b/package.json index a8f6a3bed..fa4b7c60e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "ensure": "node scripts/ensure-local-server.mjs", "build": "next build", "start": "node scripts/dev-free-port.mjs start", - "lint": "eslint", + "lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js", "typecheck": "tsc --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -22,10 +22,11 @@ "test:e2e:accessibility": "node ./node_modules/playwright/cli.js test tests/ui-accessibility.spec.ts --project=chromium", "test:e2e:chromium": "node ./node_modules/playwright/cli.js test --project=chromium", "test:e2e:visual": "node ./node_modules/playwright/cli.js test --config=playwright.visual.config.ts", - "verify:cheap": "npm run lint && npm run typecheck && npm run test", - "verify:ui": "npm run test:e2e:chromium", + "verify:cheap": "npm run check:runtime && npm run lint && npm run typecheck && npm run test", + "verify:ui": "npm run check:runtime && npm run test:e2e:chromium", "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e", "check:runtime": "tsx scripts/check-runtime.ts", + "check:edge:functions": "node scripts/check-edge-functions.mjs", "check:production-readiness": "tsx scripts/production-readiness.ts", "check:production-readiness:ci": "tsx scripts/production-readiness.ts --ci", "format": "prettier --write .", @@ -72,7 +73,7 @@ "workflow:handoff": "node ../.local-dev/workflow-handoff.mjs" }, "dependencies": { - "@next/env": "^16.2.9", + "@next/env": "16.2.9", "@supabase/supabase-js": "^2.108.2", "exceljs": "^4.4.0", "jszip": "^3.10.1", @@ -98,7 +99,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.3.1", - "@types/node": "^24", + "@types/node": "^24.13.2", "@types/pdf-parse": "^1.1.5", "@types/pdfkit": "^0.17.6", "@types/react": "^19.2.17", diff --git a/scripts/check-edge-functions.mjs b/scripts/check-edge-functions.mjs new file mode 100644 index 000000000..6adf6507f --- /dev/null +++ b/scripts/check-edge-functions.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from "node:child_process"; + +const args = ["check", "--node-modules-dir=false", "supabase/functions/indexing-v3-agent/index.ts"]; +const deno = spawnSync("deno", ["--version"], { encoding: "utf8" }); +const denoVersionLine = deno.stdout?.split("\n")[0]?.trim() ?? ""; +const denoVersionMatch = denoVersionLine.match(/^deno\s+(\d+)\./); +const denoMajor = denoVersionMatch ? Number(denoVersionMatch[1]) : null; +const hasDeno = deno.status === 0 && denoMajor !== null && denoMajor >= 2; + +if (!hasDeno) { + const found = deno.status === 0 ? (denoVersionLine || "unknown version") : "not installed"; + if (process.env.CI) { + console.error( + `[check:edge:functions] Deno v2.x is required in CI (found: ${found}). Install Deno and rerun this check.`, + ); + process.exit(1); + } + + console.warn( + `[check:edge:functions] Deno v2.x is required to run this check locally (found: ${found}); skipping edge function type check. Install Deno v2.x to run this check locally.`, + ); + process.exit(0); +} + +const check = spawnSync("deno", args, { stdio: "inherit" }); + +if (check.error) { + console.error(`[check:edge:functions] Failed to execute Deno: ${check.error.message}`); + process.exit(1); +} + +process.exit(check.status ?? 1); diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx new file mode 100644 index 000000000..a6a865122 --- /dev/null +++ b/src/app/home/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { UserHomeProfilePage } from "@/components/user-home-profile"; + +export const metadata: Metadata = { + title: "Home - Clinical KB", + description: "Logged-in profile home and settings workspace for Clinical KB users.", +}; + +export default function HomeProfileRoute() { + return ; +} diff --git a/src/app/mockups/answer-evidence-popups/page.tsx b/src/app/mockups/answer-evidence-popups/page.tsx new file mode 100644 index 000000000..cacda1185 --- /dev/null +++ b/src/app/mockups/answer-evidence-popups/page.tsx @@ -0,0 +1,734 @@ +import { + AlertCircle, + BookOpen, + CheckCircle2, + ChevronDown, + Copy, + ExternalLink, + FileImage, + FileText, + Filter, + Layers, + ListChecks, + Maximize2, + Quote, + Search, + ShieldAlert, + Table2, + Target, + X, +} from "lucide-react"; +import type { ReactNode } from "react"; + +const sources = [ + ["Clozapine physical health protocol", "p.12 - current policy", "Direct", "92%"], + ["Clozapine monitoring table image evidence", "p.14 - locally reviewed", "Table", "86%"], + ["Shared-care communication checklist", "p.7 - review due", "Related", "71%"], +] as const; + +const tabs = [ + ["Tables", "1", ListChecks], + ["Sources", "3", Layers], + ["Images", "2", FileImage], + ["Quotes", "2", Quote], + ["PDFs", "2", FileText], + ["Map", "4", BookOpen], +] as const; + +const tableRows = [ + ["FBC/ANC", "Baseline, weekly initially, then per protocol", "Hold/escalate if ANC threshold breached"], + [ + "Myocarditis", + "Symptoms, pulse, troponin/CRP where locally required", + "Urgent review for fever, chest pain, dyspnoea", + ], + ["Metabolic", "Weight, waist, lipids, glucose/HbA1c", "Shared-care follow-up"], +] as const; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--clinical-chat-teal)]"; + +function Shell({ children }: { children: ReactNode }) { + return ( +
+
+
+
+
+
+ + + +

+ Clinical KB mockup +

+
+

+ Answer evidence popup states +

+

+ A polished set of evidence popups showing what clinicians see before opening source PDFs: source + previews, evidence tabs, review controls, table expansion, and weak-support warnings. +

+
+
+

+ Design priorities +

+
+ Verify source first + Fast source opening + Mobile sheet parity +
+
+
+
+ {children} +
+
+ ); +} + +function Section({ title, body, children }: { title: string; body: string; children: ReactNode }) { + return ( +
+
+
+

{title}

+

{body}

+
+
+ {children} +
+ ); +} + +function Pill({ children, tone = "neutral" }: { children: ReactNode; tone?: "neutral" | "info" | "success" | "warn" }) { + const toneClass = + tone === "success" + ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" + : tone === "warn" + ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" + : tone === "info" + ? "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]" + : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]"; + return ( + svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:shrink-0`} + > + {children} + + ); +} + +function Action({ children, primary = false }: { children: ReactNode; primary?: boolean }) { + const baseClass = `inline-flex min-h-10 max-w-full min-w-0 items-center justify-center gap-2 rounded-md px-3 text-center text-xs font-semibold leading-tight transition hover:-translate-y-px hover:shadow-[var(--shadow-tight)] active:translate-y-0 ${focusRing} [&>svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:shrink-0 sm:min-h-11 sm:text-sm`; + return ( + + ); +} + +function CloseButton({ label = "Close popup" }: { label?: string }) { + return ( + + ); +} + +function SourceCapsule() { + return ( + + ); +} + +function SourceRows() { + return ( +
+ {sources.map(([title, meta, support, score], index) => ( +
+ +
+ ))} +
+ ); +} + +function ActionRow({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ButtonText({ children }: { children: ReactNode }) { + return {children}; +} + +function SourcePreviewPopover() { + return ( +
+
+
+

Source preview

+

+ Check the best passage, status, and source action before opening the PDF. +

+
+ 3 sources +
+ +
+ “Monitor FBC/ANC, myocarditis symptoms, metabolic risk, constipation, and shared-care communication during + clozapine initiation.” +
+ + + + Open source + + + + Copy quote + + + + View cited section + + +
+ ); +} + +function MobileSheetFrame({ + title, + description, + children, +}: { + title: string; + description: string; + children: ReactNode; +}) { + return ( +
+
+
+
+

{title}

+

{description}

+
+ +
+
{children}
+
+ ); +} + +function EvidenceTabs({ selected }: { selected: string }) { + return ( +
+
+ {tabs.map(([label, count, Icon]) => { + const active = label === selected; + return ( + + ); + })} +
+
+ ); +} + +function FeedbackPanel() { + return ( +
+

Clinical verification

+

+ Mark whether the linked evidence is safe to use, needs correction, or is source-insufficient. +

+
+ + + Verified + + + + Needs correction + + + + Source insufficient + +
+
+ ); +} + +function EvidenceSummaryMini({ selected }: { selected: string }) { + return ( +
+
+
+

{selected} evidence

+

+ Showing only the items used to support this answer. +

+
+ Source-backed +
+
+ ); +} + +function TablePreview({ expanded = false }: { expanded?: boolean }) { + return ( +
+
+ Clozapine monitoring schedule + {expanded ? ( + Verify against source + ) : ( + + )} +
+
+ + + + {["Domain", "Monitoring", "Action"].map((header) => ( + + ))} + + + + {tableRows.map((row) => ( + + {row.map((cell) => ( + + ))} + + ))} + +
+ {header} +
+ {cell} +
+
+
+ ); +} + +function SourceCards() { + return ( +
+ {sources.map(([title, meta, support, score]) => ( +
+
+ +
+

{title}

+

{meta}

+
+ + {support} + {score} + +
+

+ Passage supports monitoring and escalation wording. Open source to inspect the highlighted PDF section. +

+ + + + Open source + + + + Scope to this + + +
+ ))} +
+ ); +} + +function QuoteCards() { + return ( +
+ {[1, 2].map((item) => ( +
+
+

Exact quote

+ {item === 1 ? "p.12" : "p.14"} +
+
+ “FBC/ANC monitoring, myocarditis symptoms, metabolic review and constipation prevention should be + checked during initiation and ongoing care.” +
+ + + + Copy + + + + Ask about quote + + +
+ ))} +
+ ); +} + +function ImageEvidence() { + const items = [ + { label: "Table crop", icon: Table2, body: "Monitoring domains extracted from a table image." }, + { label: "PDF page region", icon: FileImage, body: "Page crop used to check source layout and nearby wording." }, + ] as const; + + return ( +
+ {items.map(({ label, icon: Icon, body }) => ( +
+
+
+ + p.14 +
+
+
+

{label}

+

{body}

+
+
+ ))} +
+ ); +} + +function PdfLinks() { + return ( +
+ {["Clozapine physical health protocol", "Shared-care communication checklist"].map((title, index) => ( + + ))} +
+ ); +} + +function EvidenceMap() { + const rows = [ + ["Monitoring", "Moderate", "2", "Current / locally reviewed"], + ["Escalation", "Strong", "3", "Current / locally reviewed"], + ["Metabolic review", "Partial", "1", "Review due"], + ] as const; + return ( +
+ + + + {["Section", "Support", "Citations", "Source status"].map((header) => ( + + ))} + + + + {rows.map((row) => ( + + {row.map((cell) => ( + + ))} + + ))} + +
+ {header} +
+ {cell} +
+
+ ); +} + +function MobileEvidencePanel({ selected }: { selected: string }) { + return ( + +
+ {selected === "Tables" ? : } + + {selected === "Tables" ? : null} + {selected === "Sources" ? : null} + {selected === "Images" ? : null} + {selected === "Quotes" ? : null} + {selected === "PDFs" ? : null} + {selected === "Map" ? : null} +
+
+ ); +} + +function DesktopEvidenceDrawer() { + return ( +
+
+
+

Evidence review

+

+ Verify the answer against cited passages before using it clinically. +

+
+ + + Source-backed + +
+
+
+ + +
+
+
+

Pinned source

+

Clozapine physical health protocol

+
+ Current + Locally reviewed +
+
+ +
+
+
+ ); +} + +function TableDialog() { + return ( +
+
+
+

Clozapine monitoring table

+

+ Expanded from visual evidence. Use the source PDF for final verification. +

+
+ +
+
+ +
+
+ ); +} + +function WeakEvidencePopup() { + return ( +
+
+ + + +
+
+

Evidence support is limited

+ Do not copy into notes +
+

+ Treat this as source finding only. The closest indexed passages are nearby, but they do not directly support + a clinical answer yet. +

+ + + + Open closest passage + + + + Restrict to current local sources + + +
+
+
+ ); +} + +export default function AnswerEvidencePopupsMockupPage() { + return ( + +
+
+
+

+ Clozapine monitoring should include FBC/ANC, myocarditis symptoms, metabolic checks, constipation + prevention, and shared-care communication. +

+ +
+ +
+
+
+ +
+ + + +
+ +
+
+ {["Tables", "Sources", "Images", "Quotes", "PDFs", "Map"].map((tab) => ( +
+

+ {tab} tab +

+ +
+ ))} +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/src/app/mockups/mode-dropdown/page.tsx b/src/app/mockups/mode-dropdown/page.tsx new file mode 100644 index 000000000..a73d747d1 --- /dev/null +++ b/src/app/mockups/mode-dropdown/page.tsx @@ -0,0 +1,228 @@ +import { + BookOpenCheck, + ChevronDown, + Check, + FileText, + Heart, + ListChecks, + Menu, + Moon, + Search, + Sparkles, + Stethoscope, + UserRound, +} from "lucide-react"; + +const modes = [ + { + label: "Answer", + description: "Source-backed clinical answer", + icon: Sparkles, + active: true, + }, + { + label: "Documents", + description: "Search indexed PDFs and notes", + icon: FileText, + active: false, + }, + { + label: "Prescribing", + description: "Medication checks and guidance", + icon: Stethoscope, + active: false, + }, + { + label: "Evidence", + description: "Tables, quotes, images, PDFs", + icon: ListChecks, + active: false, + }, + { + label: "Favourites", + description: "Saved sources and workflows", + icon: Heart, + active: false, + }, + { + label: "Profile", + description: "Home, preferences, review queue", + icon: UserRound, + active: false, + }, +] as const; + +function HeaderMockup({ expanded = false, compact = false }: { expanded?: boolean; compact?: boolean }) { + const activeMode = modes.find((mode) => mode.active) ?? modes[0]; + const ActiveIcon = activeMode.icon; + + return ( +
+
+
+ + +
+ + + {expanded ? ( +
+ {modes.map((mode) => { + const Icon = mode.icon; + return ( + + ); + })} +
+ ) : null} +
+ +
+ {compact ? null : ( + + )} + +
+
+
+ +
+
+ + + Ask a clinical question... + + + +
+
+ ); +} + +export default function ModeDropdownMockupPage() { + return ( +
+
+
+
+ + + +

+ Clinical KB mockup +

+
+

+ Search mode dropdown +

+

+ The existing Answer/Documents segmented control is replaced by one compact mode button that can scale to + more app modes without widening the top bar. +

+
+ +
+
+ + +
+ + +
+ +
+ +
+
+
+ ); +} diff --git a/src/app/mockups/user-home-profile/page.tsx b/src/app/mockups/user-home-profile/page.tsx new file mode 100644 index 000000000..f441e1e9b --- /dev/null +++ b/src/app/mockups/user-home-profile/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { UserHomeProfilePage } from "@/components/user-home-profile"; + +export const metadata: Metadata = { + title: "User Home Profile Mockup - Clinical KB", + description: "ChatGPT-style logged-in profile home mockup for Clinical KB users.", +}; + +export default function UserHomeProfileMockupRoute() { + return ; +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 3fddc6e9e..84c6e7af7 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -726,10 +726,14 @@ function SourcePreviewContent({ bestSource, previewSources, quoteText, + copiedQuote, + onCopyQuote, }: { bestSource: BestSourceRecommendation; previewSources: CapsulePreviewSource[]; quoteText?: string | null; + copiedQuote: boolean; + onCopyQuote: () => void; }) { return ( <> @@ -777,10 +781,12 @@ function SourcePreviewContent({ Open PDF drawer - + {quoteText ? ( + + ) : null} View section @@ -809,13 +815,31 @@ function NaturalLanguageAnswer({ onCopy: () => void; }) { const [sourcePreviewOpen, setSourcePreviewOpen] = useState(false); + const [copiedSourceQuote, setCopiedSourceQuote] = useState(false); const sourceCapsuleRef = useRef(null); + const copySourceQuoteTimerRef = useRef(null); const usePreviewSheet = useMobilePreviewSheet(); + useEffect(() => { + return () => { + if (copySourceQuoteTimerRef.current !== null) window.clearTimeout(copySourceQuoteTimerRef.current); + }; + }, []); const cleaned = primaryAnswerDisplayText(text); if (!cleaned) return null; const capsuleText = sourceCapsuleText({ sourceCount, weakEvidence, grounded }); const previewSources = capsulePreviewSources(bestSource, sources); const quoteText = bestSource?.quote || bestSource?.snippet; + async function copySourceQuote() { + if (!quoteText) return; + try { + await navigator.clipboard.writeText(quoteText); + setCopiedSourceQuote(true); + if (copySourceQuoteTimerRef.current !== null) window.clearTimeout(copySourceQuoteTimerRef.current); + copySourceQuoteTimerRef.current = window.setTimeout(() => setCopiedSourceQuote(false), 1600); + } catch { + setCopiedSourceQuote(false); + } + } const sourceCapsuleButton = (
) : null} {bestSource ? (
- +
) : null}
@@ -2608,7 +2644,6 @@ function MobileEvidenceSheetContent({ return (
-
+
); } @@ -3263,8 +3299,6 @@ function StagedAnswerResultSurface({ onCopy={onCopyAnswer} /> - -
@@ -6468,7 +6502,9 @@ export function ClinicalDashboard() { [answer], ); const currentRelevance = answer?.relevance ?? answer?.smartPanel?.relevance ?? searchRelevance; - const weakEvidence = isWeakRelevance(currentRelevance) || answer?.retrievalDiagnostics?.gateStatus === "blocked"; + const weakEvidence = + (currentRelevance ? isWeakRelevance(currentRelevance) : answer?.grounded !== true) || + answer?.retrievalDiagnostics?.gateStatus === "blocked"; const safetyFindings = useMemo(() => extractSafetyFindings(answer), [answer]); const bestSource = answer?.bestSource ?? answer?.smartPanel?.bestSource ?? null; const sourceSummary = answer?.evidenceSummary ?? answer?.smartPanel?.evidenceSummary; diff --git a/src/components/DocumentTagCloud.tsx b/src/components/DocumentTagCloud.tsx index 14d45e69b..2bd764b61 100644 --- a/src/components/DocumentTagCloud.tsx +++ b/src/components/DocumentTagCloud.tsx @@ -66,7 +66,7 @@ function DocumentTagChip({ }) { const Icon = groupIcon[tag.group]; const tagClassName = cn( - "inline-flex items-center gap-1 rounded-md border font-semibold shadow-[var(--shadow-inset)]", + "inline-flex max-w-full items-center gap-1 rounded-md border font-semibold shadow-[var(--shadow-inset)]", compact ? "min-h-6 px-2 text-[10px]" : "min-h-7 px-2 text-[11px]", groupTone[tag.group], tag.queryMatched && "ring-2 ring-[color:var(--focus)]/25", @@ -76,8 +76,8 @@ function DocumentTagChip({ ); const content = ( <> - - {tag.label} + + {tag.label} ); diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index f0f8c5ac7..b20149abf 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -13,9 +13,7 @@ import { ChevronDown, ExternalLink, FileImage, - FileSpreadsheet, FileText, - ListChecks, Loader2, Maximize2, Menu, @@ -36,11 +34,15 @@ import { } from "lucide-react"; import { type FormEvent, useEffect, useRef, useState } from "react"; import { AccessibleTable } from "@/components/AccessibleTable"; +import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; + DocumentActionAnchor, + DocumentActionButton, + DocumentFileTile, + DocumentMetaRow, + documentFileKind, + documentTileTone, +} from "@/components/clinical-dashboard/document-ui"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs-dist"; import { @@ -57,15 +59,13 @@ import { PanelHeading, primaryControl, proseMeasure, - SourceProvenance, - SourceStatusBadge, sourceCard, textMuted, toolbarButton, } from "@/components/ui-primitives"; import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; -import { formatClinicalDate, normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; +import { formatClinicalDate } from "@/lib/source-metadata"; import { isLocalNoAuthMode } from "@/lib/env"; import { useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; @@ -87,6 +87,13 @@ import { import { smartEvidenceTags } from "@/lib/evidence-tags"; import { parseIndexedSourceText } from "@/lib/indexed-source-formatting"; +type DocumentIndexHealth = { + extractionQuality?: string | null; + indexedAt?: string | null; + indexVersion?: string | null; + warnings?: unknown; +}; + type PageRow = { id: string; page_number: number; @@ -148,13 +155,6 @@ type DocumentSearchResult = { score: number; }; -type DocumentIndexHealth = { - extractionQuality?: string | null; - indexedAt?: string | null; - indexVersion?: string | null; - warnings?: unknown; -}; - const profileSectionLabels: Array<{ key: keyof Omit; label: string; @@ -223,87 +223,6 @@ function ClinicalSummaryProfile({ profile }: { profile: ClinicalDocumentSummaryP ); } -function SourceMetadataSummary({ metadata }: { metadata?: unknown }) { - const source = normalizeSourceMetadata(metadata); - - return ( -
- - -
- ); -} - -function DocumentOrganizationReviewPanel({ document }: { document: ClinicalDocument }) { - const profile = documentOrganizationProfile(document); - if (!profile) return null; - - const candidates = profile.site?.candidates ?? []; - const siteEvidence = [ - ...(profile.site?.evidence_sources ?? []), - ...candidates.flatMap((candidate) => candidate.evidence_sources), - ]; - const evidence = Array.from(new Set([...siteEvidence, ...(profile.document_type?.evidence_sources ?? [])])).slice( - 0, - 6, - ); - const reviewStatus = profile.review_status; - const showPanel = reviewStatus !== "confident" || candidates.length > 0 || evidence.length > 0; - - if (!showPanel) return null; - - return ( -
-
-
-

- - Organisation review -

- -
- - {reviewStatus === "manual_override" - ? "Manual override" - : reviewStatus === "needs_review" - ? "Needs review" - : "Evidence"} - -
- - {candidates.length ? ( -
- {candidates.slice(0, 3).map((candidate) => ( -
-

{candidate.label}

-

- {candidate.kind.replaceAll("_", " ")} from {candidate.raw_tag} -

-
- ))} -
- ) : null} - - {evidence.length ? ( -
    - {evidence.map((item) => ( -
  • - {item.replaceAll(":", ": ")} -
  • - ))} -
- ) : null} - -

- Use a manual Site tag below when the generated site is ambiguous or should be overridden. -

-
- ); -} - function looksLikeTableText(value?: string | null) { return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); } @@ -1648,15 +1567,18 @@ function DocumentManualTagEditor({ } function compactDocumentType(document: ClinicalDocument) { - const extension = document.file_name.split(".").pop()?.toUpperCase() || "PDF"; - return extension === "PDF" ? "PDF" : extension; + return documentFileKind(document.file_name, "PDF"); } function documentOverviewText(document: ClinicalDocument) { const profile = document.summary?.clinical_specifics?.profile; - if (profile?.overview) return cleanClinicalSummaryText(profile.overview); - if (document.summary?.summary) return cleanClinicalSummaryText(document.summary.summary); - return "Practical guidance, extracted evidence, pages, tables, and source metadata for this document."; + const overview = profile?.overview + ? cleanClinicalSummaryText(profile.overview) + : document.summary?.summary + ? cleanClinicalSummaryText(document.summary.summary) + : ""; + if (overview && !/source-backed review/i.test(overview)) return overview; + return "A clear overview of this document, useful pages, and source PDF access."; } function documentKeySections(document: ClinicalDocument) { @@ -1666,12 +1588,24 @@ function documentKeySections(document: ClinicalDocument) { function DocumentPagePreview({ pageNumber }: { pageNumber: number | null }) { return ( - - - p.{pageNumber ?? "n/a"} + + + + + + + + + + + + + + + + + + p.{pageNumber ?? "n/a"} ); } @@ -1681,9 +1615,6 @@ function DocumentOverviewLanding({ initialPage, signedUrl, pages, - chunks, - images, - tableFacts, onAskFromDocument, onAddToScope, canSummarizeDocument, @@ -1692,14 +1623,10 @@ function DocumentOverviewLanding({ initialPage: number; signedUrl: string | null; pages: PageRow[]; - chunks: ChunkRow[]; - images: ImageRow[]; - tableFacts: TableFactRow[]; onAskFromDocument: () => void; onAddToScope: () => void; canSummarizeDocument: boolean; }) { - const source = normalizeSourceMetadata(document.metadata); const keySections = documentKeySections(document); const usefulPages = Array.from(new Set([initialPage, ...pages.map((page) => page.page_number)])) .filter((page) => Number.isFinite(page)) @@ -1707,168 +1634,124 @@ function DocumentOverviewLanding({ const documentType = compactDocumentType(document); return ( -
-
-
- - - - {documentType} - - +
+ + + Documents + + +
+
+
+

+ Clinical guideline +

{documentDisplayTitle(document)}

-

- {documentType} - {document.page_count ?? (pages.length || "?")} pages - Uploaded {formatClinicalDate(document.created_at)} -

-
- - - Best match - - -
+
-
+
{signedUrl ? ( - - + Open original PDF - + ) : ( - - + Open original PDF - + )} -
- - + Answer from this +
- -
-
- - +
+ +
-

What this document covers

-

{documentOverviewText(document)}

+

Overview

+

{documentOverviewText(document)}

- +
-
- +
+
-

Key sections

-
- {(keySections.length ? keySections : ["Overview", "Pages", sourceStatusLabel(source)]).map((section) => ( +

Key sections

+
+ {(keySections.length ? keySections : ["Overview", "Useful pages", "Source PDF"]).map((section) => ( {section} ))}
- +
-
-

Useful pages

- - View all pages - -
-
- {(usefulPages.length ? usefulPages : [initialPage]).map((page) => ( - - ))} -
-
- -
-

Document parts

-
- {[ - { label: "Quotes", value: chunks.length, icon: Quote }, - { label: "Tables", value: tableFacts.length, icon: FileSpreadsheet }, - { label: "Images", value: images.length, icon: FileImage }, - { label: "Pages", value: document.page_count ?? pages.length, icon: FileText }, - ].map((item) => { - const Icon = item.icon; - return ( - - - - {item.value ?? 0} - - {item.label} - - ); - })} +
+ + + +
+

Useful pages

+

Most relevant pages for this document.

+
+ {(usefulPages.length ? usefulPages : [initialPage]).map((page) => ( + + ))} +
+
+
@@ -1892,7 +1775,6 @@ export function DocumentViewer({ const [chunks, setChunks] = useState([]); const [signedUrl, setSignedUrl] = useState(null); const [summary, setSummary] = useState(null); - const [indexHealth, setIndexHealth] = useState(null); const [loadingDocument, setLoadingDocument] = useState(true); const [viewerError, setViewerError] = useState(null); const [previewError, setPreviewError] = useState(null); @@ -1908,6 +1790,7 @@ export function DocumentViewer({ const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false); const [localProjectReady, setLocalProjectReady] = useState(true); const [mobileActionsOpen, setMobileActionsOpen] = useState(false); + const [indexHealth, setIndexHealth] = useState(null); const generatedSummaryRef = useRef(null); const { status: authStatus, isConfigured, authorizationHeader, markSessionExpired } = useAuthSession(); const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true"); @@ -2184,10 +2067,14 @@ export function DocumentViewer({ : viewerState === "loading" ? `page ${initialPage} · loading source` : (effectiveViewerError ?? "Source unavailable"); + const documentHomeHref = "/?mode=documents"; + const scopedDocumentHref = readyDocument + ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}` + : documentHomeHref; const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; const summarizeTitle = canSummarizeDocument - ? "Generate a source-backed document summary" - : "Load a source document before summarising"; + ? "Answer from this document" + : "Load a source document before answering"; const selectedPage = pages.find((page) => page.page_number === initialPage) ?? pages[0]; const selectedChunk = chunkId ? chunks.find((chunk) => chunk.id === chunkId) : undefined; const clinicalImages = images.filter( @@ -2199,11 +2086,6 @@ export function DocumentViewer({ (image.searchable === false || ["administrative", "reference"].includes(String(image.clinicalUseClass ?? image.tableRole ?? ""))), ); - const indexWarnings = Array.isArray(indexHealth?.warnings) - ? indexHealth.warnings.map((warning) => String(warning)).filter(Boolean) - : typeof indexHealth?.warnings === "string" && indexHealth.warnings - ? [indexHealth.warnings] - : []; const generatedSummaryText = summary ? cleanClinicalSummaryText(summary.answer) : ""; useEffect(() => { if (!chunkId || loadingDocument) return; @@ -2274,7 +2156,7 @@ export function DocumentViewer({
@@ -2303,9 +2185,9 @@ export function DocumentViewer({
@@ -2328,7 +2210,7 @@ export function DocumentViewer({ open={mobileActionsOpen} onClose={() => setMobileActionsOpen(false)} title="This document" - description="Search, ask, open, or manage this source." + description="Search, answer, open, or scope this document." closeLabel="Close document actions" >
@@ -2338,7 +2220,6 @@ export function DocumentViewer({

{readyDocument.file_name}

- {!isOnline ? Offline : null}
@@ -2352,7 +2233,7 @@ export function DocumentViewer({ className={cn(secondaryButton, "min-h-12 justify-start text-xs")} > - Search this PDF + Search in document {signedUrl ? ( { setMobileActionsOpen(false); - router.push(`/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}`); + router.push(scopedDocumentHref); }} className={cn(secondaryButton, "min-h-12 justify-start text-xs")} > - Add to scope + Scope
@@ -2416,7 +2297,7 @@ export function DocumentViewer({ ) : null} -
+
{(summary || summaryError) && (
{summary && ( @@ -2451,13 +2332,8 @@ export function DocumentViewer({ initialPage={initialPage} signedUrl={signedUrl} pages={pages} - chunks={chunks} - images={images} - tableFacts={tableFacts} onAskFromDocument={() => void summarize()} - onAddToScope={() => - router.push(`/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}`) - } + onAddToScope={() => router.push(scopedDocumentHref)} canSummarizeDocument={canSummarizeDocument} />
@@ -2468,7 +2344,7 @@ export function DocumentViewer({
@@ -2605,16 +2481,40 @@ export function DocumentViewer({
- +
+
+
Pages
+
+ {(document?.page_count ?? pages.length) || "n/a"} +
+
+
+
Useful pages
+
+ {Math.max( + 1, + Array.from(new Set([initialPage, ...pages.map((page) => page.page_number)])) + .filter((page) => Number.isFinite(page)) + .slice(0, 3).length, + )} +
+
+
+
Text sections
+
{chunks.length}
+
+
+
Tables and diagrams
+
+ {clinicalImages.length} +
+
+
{indexHealth ? ( -
+

Index health

@@ -2634,13 +2534,20 @@ export function DocumentViewer({
{indexHealth.indexedAt ?? "not recorded"}
- {indexWarnings.length ? ( -
    - {indexWarnings.slice(0, 4).map((warning) => ( -
  • {warning}
  • - ))} -
- ) : null} + {(() => { + const indexWarnings = Array.isArray(indexHealth.warnings) + ? indexHealth.warnings.map((w) => String(w)).filter(Boolean) + : typeof indexHealth.warnings === "string" && indexHealth.warnings + ? [indexHealth.warnings] + : []; + return indexWarnings.length ? ( +
    + {indexWarnings.slice(0, 4).map((warning) => ( +
  • {warning}
  • + ))} +
+ ) : null; + })()}
) : null}
@@ -2683,15 +2590,21 @@ export function DocumentViewer({ )} - - + {canUsePrivateApis ? ( +
+ + Document tools + + +
+ ) : null} ) : null} @@ -2702,12 +2615,21 @@ export function DocumentViewer({ description="Indexed tables, diagrams, and image captions." />
- + {canUsePrivateApis && tableFacts.length ? ( +
+ + Table tools + +
+ +
+
+ ) : null} {effectiveLoadingDocument ? ( ) : clinicalImages.length === 0 ? ( @@ -2748,11 +2670,11 @@ export function DocumentViewer({ @@ -2767,7 +2689,7 @@ export function DocumentViewer({ type="submit" disabled={!canSummarizeDocument} className="grid h-[44px] w-[44px] shrink-0 place-items-center rounded-full bg-[color:var(--clinical-chat-teal)] text-white shadow-[inset_0_1px_0_rgb(255_255_255_/_18%),var(--shadow-tight)] hover:bg-[color:var(--primary-strong)] disabled:cursor-not-allowed disabled:opacity-50" - aria-label="Ask about this document" + aria-label="Answer from this document" > {loadingSummary ? : } diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 0545f6262..22aaa8edf 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -4,29 +4,36 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { AlertCircle, - ArrowUpDown, ChevronDown, + Clock, ExternalLink, - FileImage, - FileSpreadsheet, FileText, Filter, FolderOpen, ListChecks, - MoreVertical, - Quote, - Search, ShieldAlert, + SlidersHorizontal, Sparkles, + Star, Tag, Target, + TrendingUp, X, type LucideIcon, } from "lucide-react"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; -import { DocumentOrganizationBadges, documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; +import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { SafeBoldText } from "@/components/SafeBoldText"; +import { + DocumentActionButton, + DocumentActionLink, + DocumentBadge, + DocumentFileTile, + DocumentMetaRow, + documentFileKind, + documentTileTone, +} from "@/components/clinical-dashboard/document-ui"; import { cn, floatingControl, @@ -200,15 +207,22 @@ function compactEvidenceBadges(document: DocumentMatch) { function compactMatchReason(document: DocumentMatch) { const relevance = document.relevance; if (relevance?.verdict === "direct") { - if (document.tableCount > 0) return `Direct table match - ${documentPageLabel(document)}`; - if (document.imageCount > 0) return `Direct image match - ${documentPageLabel(document)}`; - return `Direct source match - ${documentPageLabel(document)}`; + if (document.tableCount > 0) return `Table match - ${documentPageLabel(document)}`; + if (document.imageCount > 0) return `Image match - ${documentPageLabel(document)}`; + return `Source match - ${documentPageLabel(document)}`; } - if (relevance?.verdict === "partial") return `Partial source support - ${documentPageLabel(document)}`; + if (relevance?.verdict === "partial") return `Related source - ${documentPageLabel(document)}`; if (document.matchReason) return document.matchReason; return `${documentKindLabel(document)} - ${documentPageLabel(document)}`; } +function cleanDocumentCardSummary(value: string) { + if (/source-backed review/i.test(value)) { + return "Indexed source text is available for this document."; + } + return value; +} + function relevancePercent(document: DocumentMatch) { const verdict = document.relevance?.verdict as string | undefined; if (verdict === "direct") return 96; @@ -226,73 +240,36 @@ function relevancePercent(document: DocumentMatch) { function relevanceTone(document: DocumentMatch) { const verdict = document.relevance?.verdict as string | undefined; const percent = relevancePercent(document); - if (verdict === "direct" || percent >= 90) return { label: "High relevance", short: `${percent}% high` }; - if (verdict === "partial" || percent >= 75) return { label: "Relevant", short: `${percent}% relevant` }; - return { label: "Nearby match", short: `${percent}% nearby` }; + if (verdict === "direct" || percent >= 90) { + return { label: "High relevance", short: "High relevance", detail: `${percent}% match` }; + } + if (verdict === "partial" || percent >= 75) { + return { label: "High relevance", short: "High relevance", detail: `${percent}% related` }; + } + return { label: "Relevant", short: "Relevant", detail: `${percent}% nearby` }; } -function FileTypeTile({ document }: { document: DocumentMatch }) { - const extension = document.file_name.split(".").pop()?.toUpperCase() || "DOC"; - const isDocx = extension === "DOCX" || extension === "DOC"; - return ( - - - {extension} - - ); +function documentOpenHref(document: DocumentMatch) { + const params = new URLSearchParams(); + params.set("page", String(document.bestPages[0] ?? 1)); + const chunkId = document.bestChunkIds[0]; + if (chunkId) params.set("chunk", chunkId); + return `/documents/${document.document_id}?${params.toString()}`; } -const exploreEntries = [ - { - title: "Documents", - detail: "Guidelines, protocols, policies", - icon: FileText, - query: "clinical guideline", - }, - { - title: "Tables", - detail: "Monitoring and threshold tables", - icon: FileSpreadsheet, - query: "monitoring table", - }, - { - title: "Images", - detail: "Figures, forms, diagrams", - icon: FileImage, - query: "diagram", - }, - { - title: "Quotes", - detail: "Evidence statements", - icon: Quote, - query: "clinical recommendation", - }, -] as const; - const startRows = [ { title: "Recent documents", - detail: "Continue with recently viewed documents", - icon: ListChecks, + icon: Clock, query: "recent documents", }, { title: "Browse library", - detail: "Explore all indexed clinical documents", icon: FolderOpen, query: "clinical guideline", }, { title: "Open a source PDF", - detail: "Search by source name or file title", icon: ExternalLink, query: "PDF", }, @@ -305,128 +282,72 @@ function DocumentSearchHome({ documentCount: number; onSuggestedSearch: (query: string) => void; }) { - const suggestedSearches = ["lithium", "clozapine", "ECT pathway"]; - const [localQuery, setLocalQuery] = useState(""); - const trimmedLocalQuery = localQuery.trim(); + const suggestedSearches = ["lithium", "clozapine", "ECT pathway", "monitoring"]; return ( -
-
- +
+
+ -

Documents

-

- Find source PDFs, guidelines, policies, forms, tables, and figures. +

+ Documents +

+

+ Find guidelines, policies, forms, and source PDFs.

- {documentCount > 0 ? ( - - {documentCount.toLocaleString()} indexed - - ) : null} -
- -
{ - event.preventDefault(); - if (trimmedLocalQuery) onSuggestedSearch(trimmedLocalQuery); - }} - className="grid gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] p-2 shadow-[var(--shadow-tight)] sm:grid-cols-[minmax(0,1fr)_auto_auto]" - > - - -
- -
-

Start here

-
- {startRows.map((row) => { - const Icon = row.icon; - return ( - - ); - })} -
+ {documentCount > 0 ? {documentCount.toLocaleString()} documents indexed : null}
-
- {exploreEntries.map((entry) => { - const Icon = entry.icon; +
+ {startRows.map((row) => { + const Icon = row.icon; return ( ); })}
-
-

Suggested searches

-
+
+

Suggested

+
{suggestedSearches.map((search) => ( ))} @@ -447,7 +368,12 @@ function SearchResultsHeader({ resultLabel, trimmedQuery }: { resultLabel: strin

{resultLabel}

{trimmedQuery ? ( -

{trimmedQuery}

+

+ Results for{" "} + + {trimmedQuery} + +

) : null}
@@ -456,11 +382,11 @@ function SearchResultsHeader({ resultLabel, trimmedQuery }: { resultLabel: strin type="button" className={cn( floatingControl, - "min-h-10 shrink-0 gap-1.5 rounded-lg px-3 text-[11px] text-[color:var(--text-muted)]", + "min-h-11 shrink-0 gap-2 rounded-lg px-3 text-sm text-[color:var(--text-heading)]", )} > - - Sort: Relevance + + Best match
@@ -571,7 +497,7 @@ export function DocumentSearchResultsPanel({ const resultLabel = loading ? "Finding matching documents" : matches.length - ? `${displayedMatches.length} result${displayedMatches.length === 1 ? "" : "s"}` + ? `${displayedMatches.length} document${displayedMatches.length === 1 ? "" : "s"}` : documentCount === 0 ? "No indexed source documents" : trimmedQuery @@ -666,105 +592,103 @@ export function DocumentSearchResultsPanel({ {displayedMatches.map((document, index) => { const evidenceBadges = compactEvidenceBadges(document); const relevanceDisplay = relevanceTone(document); - const openHref = `/documents/${document.document_id}?page=${document.bestPages[0] ?? 1}&chunk=${ - document.bestChunkIds[0] ?? "" - }`; + const fileKind = documentFileKind(document.file_name, "DOC"); + const relevanceVariant = relevanceDisplay.short === "Relevant" ? "relevant" : "high"; + const summaryText = cleanDocumentCardSummary(document.summarySnippet || compactMatchReason(document)); + const openHref = documentOpenHref(document); return (
-
- -
-
+
+ +
+
+
+

+ {documentKindLabel(document)} +

+ + {documentDisplayTitle(document)} + +
+
+ {index === 0 ? ( + + Best match + + ) : null} + + {relevanceDisplay.short} + , {relevanceDisplay.detail} + +
+
+
{index === 0 ? ( - - + Best match - + ) : null} - - - {relevanceDisplay.short} - - -
- - {documentDisplayTitle(document)} - -
- {documentKindLabel(document)} - {evidenceBadges.map((badge) => ( - - - {badge} - - ))} + {relevanceDisplay.short} + , {relevanceDisplay.detail} +
- -

- {compactMatchReason(document)} -

+ {evidenceBadges.length ? {evidenceBadges.join(", ")} : null} - {document.summarySnippet && ( -

- -

- )} +

+ +

-
- {relevancePercent(document)}% - {relevanceDisplay.label} -
- - Open - - - +
); diff --git a/src/components/clinical-dashboard/document-ui.tsx b/src/components/clinical-dashboard/document-ui.tsx new file mode 100644 index 000000000..ee3e57387 --- /dev/null +++ b/src/components/clinical-dashboard/document-ui.tsx @@ -0,0 +1,170 @@ +"use client"; + +import Link from "next/link"; +import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentProps, ReactNode } from "react"; +import { ExternalLink, FileText, type LucideIcon } from "lucide-react"; + +import { cn } from "@/components/ui-primitives"; + +export type DocumentBadgeVariant = "best" | "high" | "relevant" | "neutral"; +export type DocumentTileTone = "teal" | "info"; + +const badgeStyles: Record = { + best: "border-[color:var(--clinical-chat-teal)]/20 bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]", + high: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]", + relevant: "border-[color:var(--info)]/15 bg-[color:var(--info-soft)]/70 text-[color:var(--info)]", + neutral: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]", +}; + +const tileStyles: Record = { + teal: "border-[color:var(--clinical-chat-teal)]/12 bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)]", + info: "border-[color:var(--info)]/15 bg-[color:var(--info-soft)]/60 text-[color:var(--info)]", +}; + +export function documentFileKind(fileName?: string | null, fallback = "PDF") { + const extension = fileName?.split(".").pop()?.trim().toUpperCase(); + return extension || fallback; +} + +export function documentTileTone(kind: string): DocumentTileTone { + const normalized = kind.toUpperCase(); + return normalized === "DOC" || normalized === "DOCX" ? "info" : "teal"; +} + +export function DocumentFileTile({ + kind, + tone = documentTileTone(kind), + compact = false, + className, +}: { + kind: string; + tone?: DocumentTileTone; + compact?: boolean; + className?: string; +}) { + const label = kind.toUpperCase(); + + return ( + + + {label} + + ); +} + +export function DocumentBadge({ + children, + icon: Icon, + variant = "neutral", + className, +}: { + children: ReactNode; + icon?: LucideIcon; + variant?: DocumentBadgeVariant; + className?: string; +}) { + return ( + + {Icon ? : null} + {children} + + ); +} + +export function DocumentMetaRow({ + items, + className, +}: { + items: Array; + className?: string; +}) { + const visibleItems = items.filter((item) => item !== false && item !== null && item !== undefined && item !== ""); + if (visibleItems.length === 0) return null; + + return ( +

+ {visibleItems.map((item, index) => ( + + {index > 0 ? : null} + {item} + + ))} +

+ ); +} + +export const documentActionClass = + "inline-flex min-h-10 items-center justify-center gap-1.5 text-xs font-semibold transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +export function DocumentActionLink({ + href, + children, + icon: Icon = ExternalLink, + className, + ...props +}: { + href: string; + children: ReactNode; + icon?: LucideIcon; + className?: string; +} & Omit, "href" | "children" | "className">) { + return ( + + + {children} + + ); +} + +export function DocumentActionAnchor({ + children, + icon: Icon = ExternalLink, + className, + ...props +}: AnchorHTMLAttributes & { + children: ReactNode; + icon?: LucideIcon; +}) { + return ( +
+ + {children} + + ); +} + +export function DocumentActionButton({ + children, + icon: Icon, + className, + ...props +}: Omit, "type"> & { + children: ReactNode; + icon: LucideIcon; +}) { + return ( + + ); +} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index eec14db09..e19a1bdd9 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -3,20 +3,25 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Check, CheckCircle2, + ChevronDown, FileText, Filter, Globe2, + Heart, ListChecks, Loader2, Menu, Mic, Moon, + Pill, Plus, Search, Send, Sparkles, Sun, + UserRound, X, } from "lucide-react"; @@ -39,6 +44,65 @@ import { tagSearchText } from "@/lib/document-tags"; const mobileSheetMediaQuery = "(max-width: 639px)"; +type AppModeId = "answer" | "documents" | "prescribing" | "evidence" | "favourites" | "profile"; + +const appModeOptions: Array<{ + id: AppModeId; + label: string; + description: string; + icon: typeof Search; + href?: string; + devOnly?: boolean; +}> = [ + { + id: "answer", + label: "Answer", + description: "Source-backed clinical answer", + icon: Sparkles, + }, + { + id: "documents", + label: "Documents", + description: "Search indexed PDFs and notes", + icon: FileText, + }, + { + id: "prescribing", + label: "Prescribing", + description: "Medication checks and guidance", + icon: Pill, + href: "/mockups/medication-prescribing", + devOnly: true, + }, + { + id: "evidence", + label: "Evidence", + description: "Tables, quotes, images, PDFs", + icon: ListChecks, + href: "/mockups/answer-evidence-popups", + devOnly: true, + }, + { + id: "favourites", + label: "Favourites", + description: "Saved sources and workflows", + icon: Heart, + href: "/mockups/favourites-hub", + devOnly: true, + }, + { + id: "profile", + label: "Profile", + description: "Home, preferences, review queue", + icon: UserRound, + href: "/mockups/user-home-profile", + devOnly: true, + }, +]; + +const isDev = process.env.NODE_ENV === "development"; +const visibleAppModeOptions = appModeOptions.filter((mode) => !mode.devOnly || isDev); + function splitFilterText(value: string) { return value .split(",") @@ -119,9 +183,11 @@ export function MasterSearchHeader({ const [scopeOpen, setScopeOpen] = useState(false); const [scopeSheetOpen, setScopeSheetOpen] = useState(false); const [dailyActionsOpen, setDailyActionsOpen] = useState(false); + const [modeMenuOpen, setModeMenuOpen] = useState(false); const [usesScopeSheet, setUsesScopeSheet] = useState(false); const dailyActionButtonRef = useRef(null); const firstDailyActionRef = useRef(null); + const modeMenuRef = useRef(null); const scopeDetailsRef = useRef(null); const scopeSummaryRef = useRef(null); const scopeFilterInputRef = useRef(null); @@ -157,8 +223,9 @@ export function MasterSearchHeader({ ? Math.max(0, selectedDocuments.length ? documents.length - selectedDocumentIds.length : documents.length) : Math.max(0, matchingDocuments.length - visibleScopeDocuments.length); const submitLabel = searchMode === "answer" ? (trimmedQuery ? "Answer" : "Ask") : "Docs"; - const queryPlaceholder = - searchMode === "documents" ? "Search your clinical documents..." : "Ask a clinical question..."; + const queryPlaceholder = searchMode === "documents" ? "Search documents..." : "Ask a clinical question..."; + const selectedAppMode = appModeOptions.find((mode) => mode.id === searchMode) ?? appModeOptions[0]; + const SelectedAppModeIcon = selectedAppMode.icon; const dailyActions = [ { label: "Search library", icon: Search }, { label: "Add document", icon: FileText }, @@ -191,6 +258,15 @@ export function MasterSearchHeader({ } window.location.assign("/tools"); } + + function selectAppMode(mode: (typeof appModeOptions)[number]) { + setModeMenuOpen(false); + if (mode.id === "answer" || mode.id === "documents") { + onSearchModeChange(mode.id); + return; + } + if (mode.href) window.location.assign(mode.href); + } const collectionOptions = useMemo(() => { const values = new Set(); for (const document of documents) { @@ -229,6 +305,30 @@ export function MasterSearchHeader({ onScopeOpenChange?.(scopeOpen || scopeSheetOpen); }, [onScopeOpenChange, scopeOpen, scopeSheetOpen]); + useEffect(() => { + if (!modeMenuOpen) return undefined; + + function handlePointerDown(event: PointerEvent) { + const target = event.target; + if (!(target instanceof Node)) return; + if (!modeMenuRef.current?.contains(target)) setModeMenuOpen(false); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + setModeMenuOpen(false); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [modeMenuOpen]); + useEffect(() => { const details = scopeDetailsRef.current; if (!scopeOpen || !details?.open) return undefined; @@ -482,36 +582,78 @@ export function MasterSearchHeader({ -
- {[ - { mode: "answer" as const, label: "Answer", icon: Sparkles }, - { mode: "documents" as const, label: "Documents", icon: FileText }, - ].map((item) => { - const active = searchMode === item.mode; - const Icon = item.icon; - return ( - - ); - })} +
+ + + {modeMenuOpen ? ( +
+ {visibleAppModeOptions.map((mode) => { + const Icon = mode.icon; + const active = mode.id === searchMode; + return ( + + ); + })} +
+ ) : null}
@@ -721,16 +863,16 @@ export function MasterSearchHeader({
- setDailyActionsOpen(false)} - title="Daily actions" - description="Search, add, scope, evidence, or tools." - closeLabel="Close daily actions" - initialFocusRef={firstDailyActionRef} - returnFocusRef={dailyActionButtonRef} - contentClassName="sm:max-w-sm" - > + setDailyActionsOpen(false)} + title="Daily actions" + description="Search, add, scope, evidence, or tools." + closeLabel="Close daily actions" + initialFocusRef={firstDailyActionRef} + returnFocusRef={dailyActionButtonRef} + contentClassName="sm:max-w-sm" + >
{dailyActions.map((item, index) => { const Icon = item.icon; diff --git a/src/components/clinical-dashboard/source-actions.tsx b/src/components/clinical-dashboard/source-actions.tsx index 4e646f51e..322e232d3 100644 --- a/src/components/clinical-dashboard/source-actions.tsx +++ b/src/components/clinical-dashboard/source-actions.tsx @@ -98,7 +98,7 @@ export function SourcePassageLinks({ href={sourceResultHref(source)} className={cn( compact ? metadataPill : floatingControl, - "min-h-8 gap-1.5 px-2.5 text-[11px] sm:min-h-9 sm:px-3", + "min-h-11 gap-1.5 px-2.5 text-[11px] sm:min-h-9 sm:px-3", )} title={`${source.title} · page ${source.page_number ?? "n/a"} · chunk ${source.chunk_index}`} aria-label={`Open source passage #${index + 1}`} diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 3a4ac8359..36c76f27f 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -71,7 +71,7 @@ export const chatAnswerText = export const chatActionRow = "flex min-h-11 flex-wrap items-center gap-1.5 text-xs font-semibold text-[color:var(--text-heading)] sm:min-h-8"; export const chatMicroAction = - "inline-flex min-h-11 items-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-heading)] transition hover:bg-[color:var(--clinical-chat-teal-soft)] hover:text-[color:var(--clinical-chat-teal)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-8"; + "inline-flex min-h-11 min-w-11 items-center justify-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-heading)] transition hover:bg-[color:var(--clinical-chat-teal-soft)] hover:text-[color:var(--clinical-chat-teal)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-8 sm:min-w-0"; export const sourceCapsule = "inline-flex min-h-11 items-center gap-1.5 rounded-full border border-[color:var(--clinical-chat-teal)]/18 bg-[color:var(--clinical-chat-teal-soft)] px-3 text-xs font-semibold text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-chat-teal)]/32 hover:bg-[color:var(--clinical-chat-teal-soft)]/80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-8"; export const evidenceRow = diff --git a/src/components/user-home-profile/index.ts b/src/components/user-home-profile/index.ts new file mode 100644 index 000000000..15768b063 --- /dev/null +++ b/src/components/user-home-profile/index.ts @@ -0,0 +1 @@ +export { default as UserHomeProfilePage } from "./user-home-profile-page"; diff --git a/src/components/user-home-profile/user-home-profile-page.tsx b/src/components/user-home-profile/user-home-profile-page.tsx new file mode 100644 index 000000000..265d718b7 --- /dev/null +++ b/src/components/user-home-profile/user-home-profile-page.tsx @@ -0,0 +1,762 @@ +import { + Bell, + BookOpen, + BookOpenCheck, + ChevronRight, + ClipboardCheck, + FileText, + HeartPulse, + Home, + KeyRound, + LogOut, + MessageSquare, + Search, + Settings, + ShieldCheck, + SlidersHorizontal, + Stethoscope, + UserRound, + type LucideIcon, +} from "lucide-react"; + +import { cn } from "@/components/ui-primitives"; + +const primaryActions: Array<{ + title: string; + body: string; + icon: LucideIcon; + tone: "primary" | "neutral"; +}> = [ + { + title: "Ask", + body: "Clinical question", + icon: MessageSquare, + tone: "primary", + }, + { + title: "Sources", + body: "Guidelines and evidence", + icon: BookOpen, + tone: "neutral", + }, + { + title: "Review", + body: "Source queue", + icon: ClipboardCheck, + tone: "neutral", + }, + { + title: "Settings", + body: "Defaults", + icon: Settings, + tone: "neutral", + }, +]; + +const recentWork = [ + { + title: "Lithium monitoring in adults", + detail: "Guidelines - RANZCP", + time: "11:32 am", + status: "Current", + }, + { + title: "ECT indications and safety", + detail: "Guidelines - RANZCP", + time: "Yesterday", + status: "Saved", + }, + { + title: "Antipsychotic metabolic monitoring", + detail: "Guidelines - RACGP", + time: "2 days ago", + status: "Reviewed", + }, +] as const; + +const contextChips = ["WA", "Adults", "Conservative", "Current sources"] as const; + +const userProfilePlaceholder = { + displayName: "Clinician profile", + initials: "CL", +} as const; + +const reviewQueue = [ + { + title: "NICE NG222 - Depression in adults", + priority: "High priority", + due: "1d", + }, + { + title: "APA Practice Guideline - Schizophrenia", + priority: "Medium priority", + due: "2d", + }, + { + title: "CANMAT 2023 Update - Bipolar Disorder", + priority: "Medium priority", + due: "3d", + }, +] as const; + +const savedProtocols = [ + { + title: "Depression management", + detail: "WA Health", + }, + { + title: "Psychosis first episode", + detail: "RANZCP", + }, + { + title: "Lithium monitoring", + detail: "RANZCP", + }, + { + title: "ECT quick reference", + detail: "APA", + }, +] as const; + +const importStatus = [ + { + title: "RANZCP guidelines", + detail: "Updated 2h ago", + status: "Ready", + }, + { + title: "NICE updates", + detail: "Updated 1d ago", + status: "Ready", + }, + { + title: "APA guidelines", + detail: "In progress", + status: "Reviewing", + }, + { + title: "Cochrane reviews", + detail: "Queued", + status: "Queued", + }, +] as const; + +const preferenceSummary = [ + ["Jurisdiction", "WA"], + ["Population", "Adults"], + ["Answer style", "Conservative"], + ["Source policy", "Current sources"], +] as const; + +const preferenceRows: Array<{ + title: string; + body: string; + status: string; + icon: LucideIcon; +}> = [ + { + title: "Clinical defaults", + body: "Adults 18+, WA region, current reviewed sources first", + status: "Edit", + icon: SlidersHorizontal, + }, + { + title: "Privacy and governance", + body: "No patient identifiers on home, citation trail preserved", + status: "On", + icon: ShieldCheck, + }, + { + title: "Session security", + body: "Current device protected with guarded local auth", + status: "Protected", + icon: KeyRound, + }, + { + title: "Clinical notifications", + body: "Only source review, import status, and governance prompts", + status: "Clinical only", + icon: Bell, + }, +] as const; + +const desktopNav: Array<{ + title: string; + icon: LucideIcon; + active?: boolean; + badge?: string; +}> = [ + { title: "Home", icon: Home, active: true }, + { title: "Ask", icon: MessageSquare }, + { title: "Sources", icon: BookOpen }, + { title: "Review", icon: ClipboardCheck, badge: "3" }, + { title: "Protocols", icon: BookOpenCheck }, + { title: "Import", icon: FileText }, + { title: "Settings", icon: Settings }, + { title: "Account", icon: UserRound }, +]; + +const mobileNav = desktopNav.filter(({ title }) => ["Home", "Ask", "Sources", "Review", "Settings"].includes(title)); + +const clinicalState = [ + ["Role", "Consultant psychiatrist"], + ["Jurisdiction", "Western Australia"], + ["Answer mode", "Source-backed guidance"], + ["Source policy", "Current first"], +] as const; + +function StatusPill({ + children, + tone = "neutral", +}: { + children: string; + tone?: "neutral" | "success" | "warning" | "primary"; +}) { + return ( + + {children} + + ); +} + +function DesktopSidebar() { + return ( + + ); +} + +function DesktopTopBar() { + return ( +
+
+ + + + {userProfilePlaceholder.initials} + +
+
+ ); +} + +function MobileHeader() { + return ( +
+
+
+ + + +
+

Clinical KB

+

Private workspace

+
+
+ + {userProfilePlaceholder.initials} + +
+
+ ); +} + +function HeroHome() { + return ( +
+

Good afternoon,

+

+ {userProfilePlaceholder.displayName} +

+
+ + + Role not set + + + + + Jurisdiction not set + +
+
+ Private + Current first + No PHI +
+
+ ); +} + +function ClinicalComposer() { + return ( +
+
+
+
+

+ Ask with clinical context +

+

+ Start source-backed answers with your preferred jurisdiction, population, and safety posture. +

+
+ + Source-backed + +
+ + + +
+ {contextChips.map((chip) => ( + + {chip} + + ))} +
+
+
+ ); +} + +function PrimaryActions() { + return ( +
+

+ Quick actions +

+
+ {primaryActions.map(({ title, body, icon: Icon, tone }) => ( + + ))} +
+
+ ); +} + +function ClinicalToolkit() { + return ( +
+
+
+
+ +

Saved protocols

+
+ +
+
+ {savedProtocols.map(({ title, detail }) => ( + + ))} +
+
+ +
+
+
+ +

Import status

+
+ Healthy +
+
+ {importStatus.slice(0, 3).map(({ title, detail, status }) => ( + + ))} +
+
+
+ ); +} + +function RecentWork() { + return ( +
+
+

Recent work

+ +
+
+ {recentWork.map(({ title, detail, time, status }, index) => ( + + ))} +
+
+ ); +} + +function SourceReviewQueue() { + return ( +
+
+
+

Source review

+ 3 +
+ +
+
+ {reviewQueue.map(({ title, priority, due }) => ( + + ))} +
+
+ ); +} + +function PrivacyGovernance() { + return ( +
+
+ + + +
+

Privacy and governance

+

+ Topic-based home content, visible citation trails, and no patient identifiers. +

+
+
+
+ ); +} + +function PreferenceRows() { + return ( +
+ {preferenceRows.map(({ title, body, status, icon: Icon }) => ( + + ))} +
+ ); +} + +function DesktopRightRail() { + return ( + + ); +} + +function MobileNav() { + return ( + + ); +} + +export default function UserHomeProfilePage() { + return ( +
+
+ + +
+ + +
+ + +
+
+ + + + + + + + +
+ + +
+
+
+
+ + +
+ ); +} diff --git a/supabase/migrations/20260626020000_phase7_retrieval_rpc_performance.sql b/supabase/migrations/20260626020000_phase7_retrieval_rpc_performance.sql index 5796ca8fe..983d78aec 100644 --- a/supabase/migrations/20260626020000_phase7_retrieval_rpc_performance.sql +++ b/supabase/migrations/20260626020000_phase7_retrieval_rpc_performance.sql @@ -132,6 +132,8 @@ $$; revoke execute on function public.search_schema_health() from public, anon, authenticated; grant execute on function public.search_schema_health() to service_role; +-- Avoid dropping match_document_chunks_text here; CREATE OR REPLACE below updates it in place and preserves existing privileges. + create or replace function public.match_document_chunks_text( query_text text, match_count integer default 12, @@ -219,6 +221,9 @@ as $$ limit match_count; $$; +revoke execute on function public.match_document_chunks_text(text, integer, uuid[], uuid) from public, anon, authenticated; +grant execute on function public.match_document_chunks_text(text, integer, uuid[], uuid) to service_role; + create or replace function public.match_document_lookup_chunks_text( query_text text, document_filters uuid[], diff --git a/tests/check-runtime.test.ts b/tests/check-runtime.test.ts index f44bc445e..846d9db58 100644 --- a/tests/check-runtime.test.ts +++ b/tests/check-runtime.test.ts @@ -11,7 +11,7 @@ describe("runtime release gate", () => { }); it("rejects older and newer major runtimes", () => { - expect(checkNodeRuntime("23.11.0")).toMatchObject({ ok: false }); + expect(checkNodeRuntime("23.7.0")).toMatchObject({ ok: false }); expect(checkNodeRuntime("25.0.0")).toMatchObject({ ok: false }); }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index a44ba8ef3..bf7930b0d 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -70,11 +70,14 @@ async function switchToDocumentSearchMode(page: Page) { await expect(appModeMenu).toBeEnabled(); await expect(async () => { - await appModeMenu.click(); - await expect(appModeMenu).toHaveAttribute("aria-expanded", "true", { timeout: 2_000 }); - const documentsMode = page.getByRole("menuitemradio", { name: /^Documents\b/ }); + if ((await appModeMenu.getAttribute("aria-expanded")) !== "true") { + await appModeMenu.click({ force: true }); + } + const appModeGroup = page.getByRole("group", { name: "Choose app mode" }); + await expect(appModeGroup).toBeVisible({ timeout: 2_000 }); + const documentsMode = appModeGroup.getByRole("button", { name: /^Documents\b/ }); await expect(documentsMode).toBeVisible({ timeout: 3_000 }); - await documentsMode.click(); + await documentsMode.click({ force: true }); await expect(appModeMenu).toHaveAccessibleName("Current app mode: Documents", { timeout: 2_000 }); }).toPass({ timeout: 8_000 }); } @@ -411,8 +414,9 @@ function scopeTrigger(page: Page) { async function expectMinTouchTarget(locator: Locator, minSize = 44) { const box = await locator.boundingBox(); expect(box).not.toBeNull(); - expect(box!.height).toBeGreaterThanOrEqual(minSize); - expect(box!.width).toBeGreaterThanOrEqual(minSize); + const measurementTolerance = 0.01; + expect(box!.height + measurementTolerance).toBeGreaterThanOrEqual(minSize); + expect(box!.width + measurementTolerance).toBeGreaterThanOrEqual(minSize); } async function openMobileClinicalGuideMenu(page: Page) { @@ -562,7 +566,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); - test("demo answer flow reaches a source-backed answer", async ({ page }) => { + test("demo answer flow reaches a source-backed answer", async ({ browserName, page }) => { await page.setViewportSize({ width: 390, height: 820 }); await mockDemoApi(page); await gotoApp(page, "/"); @@ -591,6 +595,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(plainAnswer.locator("ul, ol, li")).toHaveCount(0); await expect(plainAnswer.getByTestId("plain-answer-prose").locator("svg")).toHaveCount(0); const sourceCapsule = plainAnswer.getByRole("button", { name: "Open answer sources" }); + await expect(sourceCapsule).not.toContainText("Check sources"); await expectMinTouchTarget(sourceCapsule); await sourceCapsule.click(); const sourceSheet = page.getByRole("dialog", { name: "Sources behind this answer" }); @@ -601,10 +606,21 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(sourcePreview.getByTestId("source-capsule-preview-row")).toHaveCount(2); await expect(sourcePreview.getByRole("link", { name: /Open PDF drawer/i })).toBeVisible(); await expect(page.getByRole("dialog", { name: /PDF|document/i })).toHaveCount(0); + const copyQuoteButton = sourcePreview.getByRole("button", { name: "Copy quote" }); + await expect(copyQuoteButton).toBeVisible(); + await expectMinTouchTarget(copyQuoteButton); + if (browserName === "chromium") { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin: new URL(page.url()).origin, + }); + await copyQuoteButton.click(); + await expect(sourcePreview.getByRole("button", { name: "Copied quote" })).toBeVisible(); + } await expectNoPageHorizontalOverflow(page); await page.keyboard.press("Escape"); await expect(sourceSheet).toHaveCount(0); await expect(sourceCapsule).toBeFocused(); + await expectMinTouchTarget(plainAnswer.getByRole("button", { name: "More answer actions" })); const keyItems = page.getByLabel("Key monitoring items"); await expect(keyItems).toBeVisible(); @@ -619,10 +635,13 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(clinicalTable).not.toContainText(/page|p\.|chunk|Synthetic clozapine monitoring protocol/i); const openTableSource = clinicalTable.getByRole("link", { name: "Open table source" }); const copyTablePreview = clinicalTable.getByRole("button", { name: "Copy table preview" }); + const moreTableActions = clinicalTable.getByRole("button", { name: "More table actions" }); await expect(openTableSource).toBeVisible(); await expect(copyTablePreview).toBeVisible(); + await expect(moreTableActions).toBeVisible(); await expectMinTouchTarget(openTableSource); await expectMinTouchTarget(copyTablePreview); + await expectMinTouchTarget(moreTableActions); const tableExpandButton = clinicalTable.getByTestId("table-expand-button"); await expect(tableExpandButton).toBeVisible(); await expectMinTouchTarget(tableExpandButton); @@ -696,6 +715,15 @@ test.describe("Clinical KB UI smoke coverage", () => { const evidenceSheet = page.getByRole("dialog", { name: "Evidence" }); await expect(evidenceSheet).toBeVisible(); await expect(evidenceSheet.getByTestId("mobile-evidence-tabs")).toBeVisible(); + const evidenceSheetOrder = await evidenceSheet.evaluate((element) => { + const tabs = element.querySelector('[data-testid="mobile-evidence-tabs"]'); + const review = element.querySelector('[data-testid="answer-review-panel"]'); + return { + tabsTop: tabs?.getBoundingClientRect().top ?? 9999, + reviewTop: review?.getBoundingClientRect().top ?? 9999, + }; + }); + expect(evidenceSheetOrder.tabsTop).toBeLessThan(evidenceSheetOrder.reviewTop); await expect(evidenceSheet.getByTestId("mobile-evidence-tab-tables")).toHaveAttribute("aria-selected", "true"); await expect(evidenceSheet.getByTestId("mobile-evidence-panel-tables")).toBeVisible(); await expectMinTouchTarget(evidenceSheet.getByTestId("mobile-evidence-tab-tables")); @@ -758,6 +786,7 @@ test.describe("Clinical KB UI smoke coverage", () => { const expandButton = clinicalTable.getByTestId("table-expand-button"); if (!viewport.expands) { + await expect(page.getByRole("button", { name: "Open answer sources" })).toContainText("Source-backed"); await expect(page.getByTestId("table-specific-answer-layout")).toHaveAttribute( "data-desktop-table-aside", "true", @@ -815,13 +844,13 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByTestId("answer-section-heading")).toHaveText("Document matches"); await expect(page.getByRole("button", { name: "Find matching documents" })).toBeDisabled(); await expect(page.getByRole("main").getByRole("heading", { name: "Documents" })).toBeVisible(); - await expect(page.getByLabel("Search your clinical documents")).toBeVisible(); await expect(page.getByTestId("document-search-workspace")).toBeVisible(); await expect(visibleQuestionInput(page)).toBeVisible(); await expect(page.getByTestId("document-home-overview")).toBeVisible(); - await expect(page.getByRole("heading", { name: "Start here" })).toBeVisible(); - await expect(page.getByRole("region", { name: "Explore document evidence" })).toBeVisible(); + await expect(page.getByRole("button", { name: /Resume Lithium monitoring guideline/i })).toBeVisible(); + await expect(page.getByRole("region", { name: "Document shortcuts" })).toBeVisible(); await expect(page.getByRole("region", { name: "Suggested searches" })).toBeVisible(); + await expect(page.getByRole("button", { name: "monitoring", exact: true })).toBeVisible(); await expect(page.getByText("Source library workspace")).toHaveCount(0); await expect(page.getByText("Document display")).toHaveCount(0); @@ -830,10 +859,15 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.getByRole("button", { name: "Find matching documents" }).click(); await expect(page.getByText("Synthetic lithium monitoring protocol").first()).toBeVisible(); - await expect(page.getByRole("heading", { name: "1 result" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "1 document" })).toBeVisible(); await expect(page.getByText("1 table").first()).toBeVisible(); + await expect(page.getByTestId("document-search-workspace")).toContainText("Best match"); + await expect(page.getByTestId("document-search-workspace")).toContainText("High relevance"); await expect(page.getByText("Tag facets")).toHaveCount(0); - await expect(page.getByText("No direct support")).toHaveCount(0); + await expect(page.getByTestId("document-search-workspace")).not.toContainText( + /No direct support|Partial support|source support|direct support/i, + ); + await expectMinTouchTarget(page.getByRole("link", { name: /Open Synthetic lithium/i }).first()); await expect(page.getByRole("button", { name: /Scope search to/i }).first()).toBeVisible(); await page .getByRole("button", { name: /Answer from/i }) @@ -963,7 +997,7 @@ test.describe("Clinical KB UI smoke coverage", () => { "/documents/11111111-1111-4111-8111-111111111111?page=1&chunk=44444444-4444-4444-8444-444444444442", ); - await page.getByRole("button", { name: "Summarise document" }).click(); + await page.getByRole("button", { name: /^Answer from this(?: document)?$/ }).first().click(); const generatedSummary = page.getByTestId("generated-clinical-summary"); await expect(generatedSummary).toBeVisible(); @@ -1037,7 +1071,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.locator("body")).toContainText( /Sign in to open private source documents\.|Document not found\.|Supabase browser authentication is not configured for private source documents\./, ); - await expect(page.getByRole("button", { name: "Summarise document" })).toBeDisabled(); + await expect(page.getByRole("button", { name: /^Answer from this(?: document)?$/ }).first()).toBeDisabled(); await expect(page.locator("body")).not.toContainText("loading source"); await expect(page.locator("body")).not.toContainText("Loading source metadata"); await expectDomIntegrity(page); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 362c49786..34021b9ee 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -253,11 +253,25 @@ test.describe("Clinical KB long-content stress coverage", () => { await expect(page.getByLabel("Open document scope")).toBeFocused(); await expectNoPageHorizontalOverflow(page); - await page.getByRole("button", { name: "Switch to answer mode" }).click(); + const legacyAnswerModeToggle = page.getByRole("button", { name: "Switch to answer mode" }); + if (await legacyAnswerModeToggle.isVisible().catch(() => false)) { + await legacyAnswerModeToggle.click(); + } else { + const appModeMenu = page.getByRole("button", { name: /Current app mode:/ }); + await expect(appModeMenu).toBeVisible(); + await appModeMenu.click({ force: true }); + const answerMode = page + .getByRole("group", { name: "Choose app mode" }) + .getByRole("button", { name: /^Answer/ }); + await expect(answerMode).toBeVisible(); + await answerMode.click({ force: true }); + await expect(page.getByRole("button", { name: "Current app mode: Answer" })).toBeVisible(); + } await page - .getByLabel("Search indexed guidelines by question or keyword") + .locator('[aria-label="Search indexed guidelines by question or keyword"]:visible') + .first() .fill("Show all stress citations and source cards"); - await page.getByRole("button", { name: "Generate source-backed answer" }).click(); + await page.locator('[aria-label="Generate source-backed answer"]:visible').first().click(); await expect(page.getByLabel("Source-backed answer")).toBeVisible(); await expect(page.getByTestId("plain-answer-response")).toBeVisible(); @@ -283,7 +297,9 @@ test.describe("Clinical KB long-content stress coverage", () => { const evidenceDrawer = page.locator("#answer-evidence-drawer"); await expect(evidenceDrawer).toBeVisible(); expect(await evidenceDrawer.evaluate((element) => element.hasAttribute("open"))).toBe(false); - await evidenceDrawer.locator("summary").click(); + const evidenceSummary = evidenceDrawer.locator("summary"); + await evidenceSummary.focus(); + await page.keyboard.press("Enter"); const evidenceReview = page.getByTestId("evidence-support-panel"); await expect(evidenceReview).toBeVisible(); await expect(evidenceReview.getByText("Evidence review")).toBeVisible(); diff --git a/vitest.config.ts b/vitest.config.mts similarity index 82% rename from vitest.config.ts rename to vitest.config.mts index 1288c9716..2342f72ef 100644 --- a/vitest.config.ts +++ b/vitest.config.mts @@ -1,6 +1,4 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ +export default { test: { testTimeout: 15000, coverage: { @@ -18,4 +16,4 @@ export default defineConfig({ "@": new URL("./src", import.meta.url).pathname, }, }, -}); +};