diff --git a/.cursor/skills/cursor-codebase-indexing/SKILL.md b/.cursor/skills/cursor-codebase-indexing/SKILL.md new file mode 100644 index 000000000..68bc3c8d9 --- /dev/null +++ b/.cursor/skills/cursor-codebase-indexing/SKILL.md @@ -0,0 +1,234 @@ +--- +name: cursor-codebase-indexing +description: 'Set up and optimize Cursor codebase indexing for semantic code search + and @Codebase queries. + + Triggers on "cursor index", "codebase indexing", "index codebase", "cursor semantic + search", + + "@codebase", "cursor embeddings". + + ' +allowed-tools: Read, Write, Edit, Bash(cmd:*) +version: 1.0.0 +license: MIT +author: Jeremy Longshore +tags: + - saas + - cursor + - cursor-codebase +compatibility: Designed for Claude Code, also compatible with Codex and OpenClaw +--- + +# Cursor Codebase Indexing + +Set up and optimize Cursor's codebase indexing system. Indexing creates embeddings of your code, enabling `@Codebase` semantic search and improving AI context awareness across Chat, Composer, and Agent mode. + +## How Indexing Works + +``` +Your Code Files + │ + ▼ + Syntax Chunking ─── splits files into meaningful code blocks + │ + ▼ + Embedding Generation ─── converts chunks to vector representations + │ + ▼ + Vector Storage (Turbopuffer) ─── cloud-hosted nearest-neighbor search + │ + ▼ + @Codebase Query ─── your question → embedding → similarity search → relevant chunks +``` + +### Key Architecture Details + +- **Merkle tree** for change detection: only modified files are re-indexed (every 10 minutes) +- **No plaintext storage**: code is not stored server-side; only embeddings and obfuscated metadata +- **Privacy Mode compatible**: with Privacy Mode on, embeddings are computed without retaining source code +- Indexing runs in the background; small projects complete in seconds, large projects (50K+ files) may take hours initially + +## Initial Setup + +1. Open your project in Cursor +2. Indexing starts automatically on first open +3. Check status: look at the bottom status bar for "Indexing..." indicator +4. View indexed files: `Cursor Settings` > `Features` > `Codebase Indexing` > `View included files` + +### Verify Indexing Status + +The status bar shows: + +- **"Indexing..."** with progress indicator -- initial indexing in progress +- **"Indexed"** -- indexing complete, `@Codebase` queries are available +- No indicator -- indexing may be disabled or not started + +## Configuration + +### .cursorignore + +Exclude files from indexing and AI features. Place in project root. Uses `.gitignore` syntax: + +```gitignore +# .cursorignore + +# Build artifacts (large, not useful for AI context) +dist/ +build/ +out/ +.next/ +target/ + +# Dependencies +node_modules/ +vendor/ +venv/ +.venv/ + +# Generated files +*.min.js +*.min.css +*.bundle.js +*.map +*.lock + +# Large data files +*.csv +*.sql +*.sqlite +*.parquet +fixtures/ +seed-data/ + +# Secrets (defense in depth -- also use .gitignore) +.env* +**/secrets/ +**/credentials/ +``` + +### .cursorindexingignore + +Exclude files from indexing only but keep them accessible to AI features when explicitly referenced: + +```gitignore +# .cursorindexingignore + +# Large test fixtures -- don't index, but allow @Files reference +tests/fixtures/ +e2e/recordings/ + +# Documentation build output +docs/.vitepress/dist/ +``` + +**Difference:** `.cursorignore` hides files from both indexing and AI features. `.cursorindexingignore` only excludes from the index; files can still be referenced via `@Files`. + +### Default Exclusions + +Cursor automatically excludes everything in `.gitignore`. You only need `.cursorignore` for files tracked by git that you want to exclude from AI. + +## Using the Index + +### @Codebase Queries + +Ask semantic questions about your entire codebase: + +``` +@Codebase where is user authentication handled? + +@Codebase show me all API endpoints that accept file uploads + +@Codebase how does the payment processing flow work? + +@Codebase find all places where we connect to Redis +``` + +`@Codebase` performs a nearest-neighbor search using your question's embedding. It returns the most semantically similar code chunks, even if they do not contain the exact keywords you used. + +### @Codebase vs @Files vs Text Search + +| Method | When to Use | Context Cost | +| -------------- | --------------------------------------- | ------------------- | +| `@Codebase` | Discovery -- you don't know which files | High (many chunks) | +| `@Files` | You know exactly which file | Low (one file) | +| `@Folders` | You know the directory | Medium-High | +| `Ctrl+Shift+F` | Exact text/regex match | N/A (editor search) | + +Use `@Codebase` for discovery, then switch to `@Files` once you know where the code lives. + +## Optimization for Large Projects + +### Monorepo Strategy + +For monorepos with many packages, open the specific package directory instead of the root: + +```bash +# Instead of opening the entire monorepo: +cursor /path/to/monorepo # Indexes everything -- slow + +# Open the specific package: +cursor /path/to/monorepo/packages/api # Indexes only this package -- fast +``` + +Or use `.cursorignore` at the root to exclude packages you are not actively working on: + +```gitignore +# .cursorignore -- monorepo, focus on api and shared +packages/web/ +packages/mobile/ +packages/admin/ +# packages/api/ ← not listed, so it IS indexed +# packages/shared/ ← not listed, so it IS indexed +``` + +### Re-Indexing + +If search results are stale or indexing appears stuck: + +1. `Cmd+Shift+P` > `Cursor: Resync Index` +2. Wait for status bar to show indexing progress +3. If that fails, delete the local cache: + - macOS: `~/Library/Application Support/Cursor/Cache/` + - Linux: `~/.config/Cursor/Cache/` + - Windows: `%APPDATA%\Cursor\Cache\` +4. Restart Cursor and allow full re-index + +### File Watcher Limits (Linux) + +On Linux, large projects may hit the file watcher limit: + +```bash +# Check current limit +cat /proc/sys/fs/inotify/max_user_watches + +# Increase (temporary) +sudo sysctl fs.inotify.max_user_watches=524288 + +# Increase (permanent) +echo "fs.inotify.max_user_watches=524288" | sudo tee -a /etc/sysctl.conf +sudo sysctl -p +``` + +## Enterprise Considerations + +- **Data residency**: Embeddings are stored in Turbopuffer (cloud). Obfuscated filenames and no plaintext code, but metadata exists +- **Privacy Mode**: With Privacy Mode on, embeddings are computed with zero data retention at the provider +- **Air-gapped environments**: Indexing requires network access to Cursor's embedding API. Not available offline +- **Indexing scope**: Only files in the currently open workspace are indexed. Closing a project removes its index from active queries + +## Troubleshooting + +| Symptom | Cause | Fix | +| ---------------------------- | ----------------------------------- | -------------------------------- | +| @Codebase returns no results | Index not built | Wait for "Indexed" in status bar | +| Search misses known files | File in .gitignore or .cursorignore | Check ignore files | +| Indexing stuck at N% | Large project or network issue | Resync index via Command Palette | +| Stale results after refactor | Index not yet updated | Wait 10 min or manual resync | +| High CPU during indexing | Initial embedding computation | Normal for first run; subsides | + +## Resources + +- [Codebase Indexing Docs](https://docs.cursor.com/context/codebase-indexing) +- [Ignore Files](https://docs.cursor.com/context/ignore-files) +- [Secure Codebase Indexing](https://cursor.com/blog/secure-codebase-indexing) diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 000000000..1e7783f74 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,42 @@ +# Cursor semantic index exclusions — keep search focused on source, not artifacts. +# Mirrors .gitignore noise; Cursor still indexes tracked source under src/, worker/, scripts/, supabase/. + +node_modules/ +.next/ +out/ +build/ +coverage/ +output/ +test-results/ +playwright-report/ +playwright-cli/ +.playwright-cli/ +playwright/.auth/ + +# Local env and secrets +.env* + +# Generated / machine-local +*.tsbuildinfo +sample-documents/ +tmp/ +.tmp-visual/ +.codex-screenshots/ +artifacts/ +scratch/ +.qa-smoke/ +.impeccable/ +supabase/.temp/ + +# Logs and PIDs +*.log +*.pid +dev-server*.log +worker-*.log + +# Python caches +__pycache__/ +.pytest_cache/ + +# Large binary / review scratch (not application logic) +docs/mockups/ diff --git a/.cursorindexingignore b/.cursorindexingignore new file mode 100644 index 000000000..38d3dd0da --- /dev/null +++ b/.cursorindexingignore @@ -0,0 +1,12 @@ +# Exclude from Cursor semantic index only — still reachable via @Files when needed. + +# Golden eval fixtures (large JSON, low day-to-day search value) +scripts/fixtures/ + +# Playwright auth state and reports (already in .cursorignore via .gitignore patterns) +playwright/.auth/ +playwright-report/ + +# Local temp clones from tooling (if present) +.tmp-skills-clone/ +.tmp-adeonir-skills/ diff --git a/.env.example b/.env.example index 4c83345a4..62cee8871 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ SUPABASE_PROJECT_REF=sjrfecxgysukkwxsowpy SUPABASE_PROJECT_NAME=Clinical KB Database NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# Edge Function only (indexing-v3-agent cron auth). Not read by Next.js env.ts. +# Set in Supabase Edge Function secrets / deployment env, not in the browser bundle. INDEXING_V3_AGENT_SECRET=your-long-random-cron-shared-secret # Local-only no-auth mode (development only). @@ -23,6 +25,8 @@ INDEXING_V3_AGENT_SECRET=your-long-random-cron-shared-secret # to OpenAI for embeddings, captioning, and grounded answer generation. OPENAI_API_KEY=replace-with-openai-api-key OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# Must match vector(N) in supabase/schema.sql. Do not change without a migration. +EMBEDDING_DIMENSIONS=1536 OPENAI_ANSWER_MODEL=gpt-5.5 OPENAI_FAST_ANSWER_MODEL=gpt-5.5 # Strong tier stays on the standard (non-pro) model; fast vs strong differ by reasoning effort. @@ -62,6 +66,11 @@ RAG_SEARCH_CACHE_TTL_MS=60000 RAG_SEARCH_CACHE_SIZE=200 RAG_AWAIT_QUERY_LOGS=false +# Privacy / production safety +# Explicit demo opt-in. Blocked by npm run check:production-readiness in production. +#NEXT_PUBLIC_DEMO_MODE=false +# Persist raw clinical query text in logs. Default off; blocked in production readiness. +#RAG_PERSIST_RAW_QUERY_TEXT=false # Server-side key for the redacted query-hash placeholder (min 16 chars). # When set, stored query hashes are HMAC-SHA256 keyed pseudonyms — not # offline-reversible and not correlatable outside this deployment. Strongly @@ -92,7 +101,7 @@ WORKER_HEALTH_BACKOFF_MS=120000 WORKER_MAX_CLAIM_FAILURES=3 WORKER_PROGRESS_UPDATE_MIN_INTERVAL_MS=60000 WORKER_MAX_CAPTIONED_IMAGES_PER_DOCUMENT=15 -WORKER_MAX_CAPTIONED_IMAGES_PER_PAGE=4 +WORKER_MAX_CAPTIONED_IMAGES_PER_PAGE=2 WORKER_VISION_CONCURRENCY=4 WORKER_INLINE_ENRICHMENT=false PYTHON_BIN=python diff --git a/.gitignore b/.gitignore index cbed281a5..6038292b4 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ next-env.d.ts # agent/QA artifacts .codex-screenshots/ +/docs/mockups/ # design/UX review scratch dumps (favourites-review, tools-page-review, etc.) — never commit artifacts/ # local hook tool cache — machine-local, never commit diff --git a/AGENTS.md b/AGENTS.md index e9dcda623..6d5a4643d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,7 +253,7 @@ After completing `upload`, summarize the current branch and worktree state, whet ## Codex productivity defaults - Treat terse prompts as workflow shortcuts when the intent is clear. If the user says `run`, execute `npm run ensure`, verify the project identity through that helper, and return the printed local URL without a long log dump. -- For non-trivial changes, start from concrete repo state: branch, `git status`, relevant package scripts, recent failures, and local logs such as `dev-server.log` when runtime behavior is involved. +- For non-trivial changes, start from concrete repo state: branch, `git status`, relevant package scripts, recent failures, and local logs such as `dev-server.log` when runtime behavior is involved. For architecture and module orientation, read `docs/codebase-index.md` (routes: `docs/site-map.md`). - For UI, browser, styling, routing, accessibility, or screenshot work, run `npm run ensure` before opening the app, then use browser QA and the smallest relevant UI proof before broader gates. - Prefer the smallest failing check first. For this repo, use focused Vitest or Playwright targets before widening to `npm run verify:cheap`, `npm run verify:ui`, or `npm run verify:release`. - When the user says `safely`, preserve unrelated staged, unstaged, and untracked work; stop only clearly repo-owned transient processes; and verify the result instead of doing broad cleanup. diff --git a/COLOR_REDESIGN_PLAN.md b/COLOR_REDESIGN_PLAN.md index e7c9c090f..e9fb3322a 100644 --- a/COLOR_REDESIGN_PLAN.md +++ b/COLOR_REDESIGN_PLAN.md @@ -1,3 +1,7 @@ +> **SUPERSEDED — historical exploration only.** Do not implement from this file. +> Active design direction: [`docs/redesign/02-design-direction.md`](docs/redesign/02-design-direction.md) +> (Clinical White / Aegean Graphite). + # Luxury Black-First Color Redesign Plan (Global UI Polish) ## 1) Intent diff --git a/README.md b/README.md index ac10276e3..2fb85c7b6 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,18 @@ questions with source citations that link back to the original PDF/document. ## Setup 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: + `.node-version` pin the same runtime for local version managers. CI also runs + `npm run check:edge:functions`, which requires Deno v2.x. +2. Install dependencies: + +```bash +npm install +``` + +3. Copy the full `.env.example` to `.env.local` and fill in Supabase and OpenAI + values. Copy the worker and upload defaults too — they are conservative + local-first settings, not optional extras. +4. Confirm the Supabase target: ```bash npm run check:supabase-project @@ -28,19 +37,36 @@ Do not use the older unused Supabase project `Database` (`qjgitjyhxrwxsrydablr`). Local checks and runtime guards warn or fall back to 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 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 +5. Database bootstrap: + +- **Existing `Clinical KB Database` project:** migrations are already applied on + live. Normal local dev does not need a SQL editor bootstrap step. +- **New staging or fresh database:** link the Supabase CLI to the project, then + apply committed migrations when local and remote histories align: + +```bash +npx supabase link --project-ref sjrfecxgysukkwxsowpy +npx supabase migration list --linked +npx supabase db push +``` + +Treat `supabase/schema.sql` as a reconciled reference mirror, not the primary +onboarding path. For drift, repair policy, and live-only caveats, see +`docs/supabase-migration-reconciliation.md` and the retrieval RPC section in +`docs/process-hardening.md`. + +6. 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: +7. Install optional PDF/OCR worker dependencies: ```bash python -m pip install -r worker/python/requirements.txt ``` -7. Start the app: +8. Start the app: ```bash npm run dev @@ -63,7 +89,7 @@ belongs to this project, and starts the dev server in the background if needed. When you say `run` in this chat, Codex should use this command and return the printed URL. -8. In a second terminal, start the local ingestion worker: +9. In a second terminal, start the local ingestion worker: ```bash npm run worker @@ -71,6 +97,8 @@ npm run worker The Next.js API stores uploads and queues ingestion jobs. The worker performs heavy parsing, OCR, image captioning, chunking, embedding, and database inserts. +It uses the conservative worker defaults from `.env.example` when those vars are +set in `.env.local`. ## Environment Notes @@ -99,8 +127,32 @@ heavy parsing, OCR, image captioning, chunking, embedding, and database inserts. and TGA Software as a Medical Device screening where applicable. - See `docs/clinical-governance.md` for the deployment governance checklist. +## Documentation + +- `docs/process-hardening.md` — verification gates, CI expectations, known limits +- `docs/clinical-governance.md` — deployment and source governance checklist +- `docs/reindex-runbook.md` — safe reindex and ingestion recovery +- `docs/retrieval-quality-runbook.md` — RAG/retrieval eval gates +- `docs/codex-prompt-playbook.md` — copy/paste prompts for common repo work +- `docs/supabase-migration-reconciliation.md` — migration drift and repair policy +- `docs/site-map.md` — generated route map (`npm run sitemap:update`) + ## Commands +Verification gates (see `package.json` for the full chain): + +```bash +npm run verify:cheap # check:runtime + sitemap:check + lint + typecheck + test +npm run verify:ui # check:runtime + test:e2e:chromium +npm run verify:release # check:runtime + lint + typecheck + test + build + test:e2e + # + check:production-readiness + governance:release + # + eval:quality:release (needs live Supabase + OpenAI keys) +``` + +CI runs `format:check` in the `verify` job alongside lint, typecheck, +test:coverage, build, and edge-function typecheck. PRs also run the Chromium +`ui-smoke` job in parallel. + ```bash npm run dev # Next.js UI/API on this project's stable localhost port npm run ensure # check/start this project's dev server in the background @@ -120,9 +172,6 @@ npm run test:e2e:all npm run test:e2e:accessibility npm run test:e2e:chromium npm run test:e2e:visual -npm run verify:cheap -npm run verify:ui -npm run verify:release npm run check:deployment-readiness npm run format npm run format:check diff --git a/docs/archive/branch-cleanup-2026-06-28.md b/docs/archive/branch-cleanup-2026-06-28.md new file mode 100644 index 000000000..4503331c5 --- /dev/null +++ b/docs/archive/branch-cleanup-2026-06-28.md @@ -0,0 +1,165 @@ +# Branch Cleanup Snapshot — 2026-06-28 + +Archived from `docs/branch-cleanup-guide.md` on 2026-07-04. This is a frozen historical record only; do not treat branch names, SHAs, or recommendations as current state. Use `docs/branch-cleanup-guide.md` for the live procedure. + +## Current Branch State + +Baseline: + +- `main` and `origin/main`: `9bad09523` (`Merge pull request #83 from BigSimmo/codex/80-20-remediation`) +- Current branch: `codex/rag-retry-telemetry-main` +- Current branch status: behind `origin/codex/rag-retry-telemetry-main` by 1 commit, with substantial uncommitted source changes. Do not switch, reset, or clean this worktree as part of branch cleanup. + +## Cleanup Progress + +Completed on 2026-06-28: + +- Deleted local branch `claude/quizzical-bhaskara-97feda` after confirming it had no patch-unique content beyond `main`. +- Deleted remote branch `origin/revert-72-codex/backup-20260623-233849` after confirming it was only the rollback branch for `Save Codex changes`. +- Removed clean detached worktrees `C:\Dev\Apps\Database-80-20-clean` and `C:\Users\joshs\.codex\worktrees\4468\Database` after confirming their HEAD commits were contained in `main`. +- Reviewed `origin/fix/rag-pipeline-stage3-generation`, `claude/recursing-agnesi-28f476`, and `codex/80-20-remediation`; kept them because they still contain useful or not-yet-committed work. +- Reviewed `copilot/simplify-operational-tooling`; rejected it as a branch to preserve because useful source/test overlap is covered by the current dirty tree or `codex/80-20-remediation`, while the remaining unique content is generated/local agent files, broad dependency-repair tooling, or duplicated env/startup patterns. + +Windows left some unregistered `.claude/worktrees/*` folders locked on disk during cleanup. Treat those as filesystem leftovers, not branch refs, after confirming they do not appear in `git worktree list --porcelain`. + +## Delete Candidates Already On Main + +These add no patch content beyond `main`: + +- None known after the 2026-06-28 cleanup pass. + +## Keep For Review + +These have patch content not represented on `main`. Do not delete before review. + +### `claude/recursing-agnesi-28f476` + +Status: + +- Local branch checked out in `C:\Dev\Apps\Database\.claude\worktrees\recursing-agnesi-28f476` +- 3 patch-unique commits +- 18 files changed + +Content: + +- Node 24 / npm 11 runtime requirement work. +- Retrieval and ingestion performance work. +- HNSW `ef_search = 100` migration and schema updates. +- Changes touch `src/lib/rag.ts`, worker code, runtime scripts, docs, and tests. + +Recommendation: + +- Keep. +- Review and salvage selectively. +- Do not merge wholesale until conflicts with current `main` are resolved and runtime expectations are confirmed. +- The local dirty worktree already appears to include Node 24/npm 11, HNSW `ef_search`, and related committed-generation work. Delete this branch only after that work is committed, ported, or explicitly rejected. + +### `codex/80-20-remediation` + +Status: + +- Local branch only; upstream branch was deleted after its remote content was confirmed merged. +- 8 patch-unique commits +- 19 files changed + +Content: + +- Additional local remediation beyond the already merged PR #83 branch. +- Eval/search privacy fixes. +- Relevance score component. +- Chunking, image filtering, answer formatting, search interaction, and UI smoke test updates. + +Recommendation: + +- Keep for review. +- Cherry-pick or port useful fixes after comparing against current uncommitted work on `codex/rag-retry-telemetry-main`. +- Delete only after useful changes are merged or consciously rejected. +- Review found unported-looking pieces such as `src/components/clinical-dashboard/relevance-score.ts`, `tests/document-relevance-score.test.ts`, and `tests/search-interaction-route.test.ts`, so this branch should remain until those are accepted or rejected. + +### `copilot/simplify-operational-tooling` + +Status: + +- Local branch only; remote branch was deleted because its remote patch content was already represented on `main`. +- 3 patch-unique local commits +- 113 files changed +- Rejected during phase 3 cleanup review. + +Content: + +- Large mixed branch with tooling scripts, local agent skill files, Node/runtime updates, mockups, dashboard and profile UI work, answer formatting changes, and broad tests. + +Recommendation: + +- Delete the local branch ref. +- Do not port `.agents/**`, `skills-lock.json`, `scripts/repair-node-modules.cjs`, `scripts/ensure-next-runtime.mjs`, `scripts/next-build.mjs`, or `scripts/run-local-tool.mjs` from this branch. +- Do not port `src/lib/startup-check.ts` or `src/lib/supabase/env.ts`; current env handling already lives in `src/lib/env.ts`, `src/lib/supabase/client.tsx`, `scripts/check-ci-env.mjs`, and Supabase project checks. +- Keep using `codex/80-20-remediation` as the review source for `relevance-score`, search interaction/eval privacy fixes, and focused tests. + +## Remote Branches Not On Main + +### `origin/claude/recursing-agnesi-28f476` + +Status: + +- 1 patch-unique commit +- 14 files changed + +Content: + +- Remote subset of the local `claude/recursing-agnesi-28f476` work, mainly Node 24 / npm 11 runtime requirement changes. + +Recommendation: + +- Keep while local `claude/recursing-agnesi-28f476` is under review. +- Delete remote only after deciding whether to keep the runtime upgrade work. + +### `origin/fix/rag-pipeline-stage3-generation` + +Status: + +- 1 patch-unique commit +- 7 files changed +- Reviewed during cleanup pass and kept. + +Content: + +- Old Stage 3 generation safety fixes. +- Touches answer verification, RAG routing, RAG trust tests, and ingestion retry route. +- Also includes committed-index-generation filtering in RAG source expansion and answer-prose guardrails. + +Recommendation: + +- Keep for clinical-safety review. +- Compare against current answer verification and ingestion retry code before deciding. +- If still relevant, port the useful tests/fixes rather than merging blindly. +- Current dirty worktree already appears to contain the numeric-verification fixes, retry-route TOCTOU guard, committed-index-generation filtering, and answer prose guardrails. Keep the branch until those changes are committed or safely represented elsewhere. + +## Detached Worktrees + +These are not branch refs, so do not treat them as branch cleanup until inspected separately. + +Removed after clean/main-contained verification: + +- `C:\Dev\Apps\Database-80-20-clean` +- `C:\Users\joshs\.codex\worktrees\4468\Database` + +Required checks before removing either: + +```powershell +git -C PATH status --short --branch +git -C PATH log -1 --oneline +git worktree list --porcelain +``` + +Remove only if clean and no longer needed: + +```powershell +git worktree remove PATH +``` + +## Recommended Next Cleanup Order (2026-06-28) + +1. Commit, port, or explicitly reject the current dirty work that appears to subsume `origin/fix/rag-pipeline-stage3-generation` and parts of `claude/recursing-agnesi-28f476`. +2. Review local `codex/80-20-remediation`; port useful extra remediation or delete. +3. Delete reviewed branches only after their useful changes are either represented in committed history or intentionally rejected. diff --git a/docs/branch-cleanup-guide.md b/docs/branch-cleanup-guide.md index 655fde0c5..d2b177e4c 100644 --- a/docs/branch-cleanup-guide.md +++ b/docs/branch-cleanup-guide.md @@ -1,9 +1,11 @@ # Branch Cleanup Guide -Last reviewed: 2026-06-28 +Last reviewed: 2026-07-04 This guide defines the safe branch cleanup path for this repository. It is written for branch hygiene only: do not use it to discard source work, resolve merge conflicts, merge product changes, or rewrite history. +For historical cleanup snapshots (frozen branch inventories and progress logs), see `docs/archive/`. + ## Goals - Keep `main`, the current working branch, and any branch with useful content not yet represented on `main`. @@ -48,167 +50,12 @@ Before deleting anything: Delete a branch only when the cherry-pick-aware log is empty, or when the branch is deliberately rejected as not useful after review. -## Current Branch State - -Baseline: - -- `main` and `origin/main`: `9bad09523` (`Merge pull request #83 from BigSimmo/codex/80-20-remediation`) -- Current branch: `codex/rag-retry-telemetry-main` -- Current branch status: behind `origin/codex/rag-retry-telemetry-main` by 1 commit, with substantial uncommitted source changes. Do not switch, reset, or clean this worktree as part of branch cleanup. - -## Cleanup Progress - -Completed on 2026-06-28: - -- Deleted local branch `claude/quizzical-bhaskara-97feda` after confirming it had no patch-unique content beyond `main`. -- Deleted remote branch `origin/revert-72-codex/backup-20260623-233849` after confirming it was only the rollback branch for `Save Codex changes`. -- Removed clean detached worktrees `C:\Dev\Apps\Database-80-20-clean` and `C:\Users\joshs\.codex\worktrees\4468\Database` after confirming their HEAD commits were contained in `main`. -- Reviewed `origin/fix/rag-pipeline-stage3-generation`, `claude/recursing-agnesi-28f476`, and `codex/80-20-remediation`; kept them because they still contain useful or not-yet-committed work. -- Reviewed `copilot/simplify-operational-tooling`; rejected it as a branch to preserve because useful source/test overlap is covered by the current dirty tree or `codex/80-20-remediation`, while the remaining unique content is generated/local agent files, broad dependency-repair tooling, or duplicated env/startup patterns. - -Windows left some unregistered `.claude/worktrees/*` folders locked on disk during cleanup. Treat those as filesystem leftovers, not branch refs, after confirming they do not appear in `git worktree list --porcelain`. - -## Delete Candidates Already On Main - -These add no patch content beyond `main`: - -- None known after the 2026-06-28 cleanup pass. - -## Keep For Review - -These have patch content not represented on `main`. Do not delete before review. - -### `claude/recursing-agnesi-28f476` - -Status: - -- Local branch checked out in `C:\Dev\Apps\Database\.claude\worktrees\recursing-agnesi-28f476` -- 3 patch-unique commits -- 18 files changed - -Content: - -- Node 24 / npm 11 runtime requirement work. -- Retrieval and ingestion performance work. -- HNSW `ef_search = 100` migration and schema updates. -- Changes touch `src/lib/rag.ts`, worker code, runtime scripts, docs, and tests. - -Recommendation: - -- Keep. -- Review and salvage selectively. -- Do not merge wholesale until conflicts with current `main` are resolved and runtime expectations are confirmed. -- The local dirty worktree already appears to include Node 24/npm 11, HNSW `ef_search`, and related committed-generation work. Delete this branch only after that work is committed, ported, or explicitly rejected. - -### `codex/80-20-remediation` - -Status: - -- Local branch only; upstream branch was deleted after its remote content was confirmed merged. -- 8 patch-unique commits -- 19 files changed - -Content: - -- Additional local remediation beyond the already merged PR #83 branch. -- Eval/search privacy fixes. -- Relevance score component. -- Chunking, image filtering, answer formatting, search interaction, and UI smoke test updates. - -Recommendation: - -- Keep for review. -- Cherry-pick or port useful fixes after comparing against current uncommitted work on `codex/rag-retry-telemetry-main`. -- Delete only after useful changes are merged or consciously rejected. -- Review found unported-looking pieces such as `src/components/clinical-dashboard/relevance-score.ts`, `tests/document-relevance-score.test.ts`, and `tests/search-interaction-route.test.ts`, so this branch should remain until those are accepted or rejected. - -### `copilot/simplify-operational-tooling` - -Status: - -- Local branch only; remote branch was deleted because its remote patch content was already represented on `main`. -- 3 patch-unique local commits -- 113 files changed -- Rejected during phase 3 cleanup review. - -Content: - -- Large mixed branch with tooling scripts, local agent skill files, Node/runtime updates, mockups, dashboard and profile UI work, answer formatting changes, and broad tests. - -Recommendation: - -- Delete the local branch ref. -- Do not port `.agents/**`, `skills-lock.json`, `scripts/repair-node-modules.cjs`, `scripts/ensure-next-runtime.mjs`, `scripts/next-build.mjs`, or `scripts/run-local-tool.mjs` from this branch. -- Do not port `src/lib/startup-check.ts` or `src/lib/supabase/env.ts`; current env handling already lives in `src/lib/env.ts`, `src/lib/supabase/client.tsx`, `scripts/check-ci-env.mjs`, and Supabase project checks. -- Keep using `codex/80-20-remediation` as the review source for `relevance-score`, search interaction/eval privacy fixes, and focused tests. - -## Remote Branches Not On Main - -### `origin/claude/recursing-agnesi-28f476` - -Status: - -- 1 patch-unique commit -- 14 files changed - -Content: - -- Remote subset of the local `claude/recursing-agnesi-28f476` work, mainly Node 24 / npm 11 runtime requirement changes. - -Recommendation: - -- Keep while local `claude/recursing-agnesi-28f476` is under review. -- Delete remote only after deciding whether to keep the runtime upgrade work. - -### `origin/fix/rag-pipeline-stage3-generation` - -Status: - -- 1 patch-unique commit -- 7 files changed -- Reviewed during cleanup pass and kept. - -Content: - -- Old Stage 3 generation safety fixes. -- Touches answer verification, RAG routing, RAG trust tests, and ingestion retry route. -- Also includes committed-index-generation filtering in RAG source expansion and answer-prose guardrails. - -Recommendation: - -- Keep for clinical-safety review. -- Compare against current answer verification and ingestion retry code before deciding. -- If still relevant, port the useful tests/fixes rather than merging blindly. -- Current dirty worktree already appears to contain the numeric-verification fixes, retry-route TOCTOU guard, committed-index-generation filtering, and answer prose guardrails. Keep the branch until those changes are committed or safely represented elsewhere. - -## Detached Worktrees - -These are not branch refs, so do not treat them as branch cleanup until inspected separately. - -Removed after clean/main-contained verification: - -- `C:\Dev\Apps\Database-80-20-clean` -- `C:\Users\joshs\.codex\worktrees\4468\Database` - -Required checks before removing either: - -```powershell -git -C PATH status --short --branch -git -C PATH log -1 --oneline -git worktree list --porcelain -``` - -Remove only if clean and no longer needed: - -```powershell -git worktree remove PATH -``` - -## Recommended Next Cleanup Order +## Recommended Cleanup Order -1. Commit, port, or explicitly reject the current dirty work that appears to subsume `origin/fix/rag-pipeline-stage3-generation` and parts of `claude/recursing-agnesi-28f476`. -2. Review local `codex/80-20-remediation`; port useful extra remediation or delete. -3. Delete reviewed branches only after their useful changes are either represented in committed history or intentionally rejected. +1. Fetch and inspect current branch state with the commands above. +2. For each candidate branch, confirm patch-unique commits and file diffs against `main`. +3. Port, commit, or explicitly reject useful patch content before deleting any branch ref. +4. Remove detached worktrees only when clean, unneeded, and absent from active `git worktree list` output. ## Final Verification @@ -223,6 +70,6 @@ git status --short --branch Expected invariant: -- `main` remains unchanged. -- The current dirty worktree remains untouched. +- `main` remains unchanged unless you intentionally merge or push there. +- The current dirty worktree remains untouched unless you explicitly choose to clean it. - No branch with patch-unique commits is deleted unless its content was explicitly rejected or safely ported first. diff --git a/docs/clinical-chat-ui-phase-checklist.md b/docs/clinical-chat-ui-phase-checklist.md index 9394eac02..1d2f57ded 100644 --- a/docs/clinical-chat-ui-phase-checklist.md +++ b/docs/clinical-chat-ui-phase-checklist.md @@ -261,16 +261,15 @@ Goal: Screens to capture: -- [ ] Desktop default answer. -- [ ] Desktop sidebar collapsed. -- [ ] Desktop Evidence opened. -- [ ] Desktop source preview. -- [ ] Desktop Documents mode. -- [ ] Desktop empty state. -- [ ] Mobile default answer. -- [ ] Mobile `+` sheet. -- [ ] Mobile Evidence opened. -- [ ] Mobile Documents mode. +- [x] Desktop sidebar collapsed +- [ ] Desktop Evidence opened +- [ ] Desktop source preview +- [x] Desktop Documents mode +- [ ] Desktop empty state +- [x] Mobile default answer +- [ ] Mobile `+` sheet +- [ ] Mobile Evidence opened +- [x] Mobile Documents mode Polish checks: diff --git a/docs/codebase-index.md b/docs/codebase-index.md new file mode 100644 index 000000000..31ab8aee5 --- /dev/null +++ b/docs/codebase-index.md @@ -0,0 +1,286 @@ +# Clinical KB — Codebase Index + +Structured map for AI agents and onboarding. For live routes, see `docs/site-map.md` (`npm run sitemap:update` / `sitemap:check`). For agent rules and verification gates, see `AGENTS.md`. + +**Stack:** Next.js 16, React 19, Supabase (pgvector, Storage, Auth), OpenAI, Python OCR worker. +**Live Supabase:** `Clinical KB Database` — ref `sjrfecxgysukkwxsowpy` (never use stale `qjgitjyhxrwxsrydablr`). + +--- + +## Quick start + +| Step | Command | +| --------------------------------- | -------------------------------- | +| Confirm Supabase target | `npm run check:supabase-project` | +| Start app (project-specific port) | `npm run ensure` | +| Start ingestion worker | `npm run worker` | +| Cheap verification gate | `npm run verify:cheap` | +| UI verification gate | `npm run verify:ui` | + +--- + +## Top-level layout + +| Path | Purpose | +| ----------- | ---------------------------------------------------------------- | +| `src/` | Next.js App Router UI, API routes, shared lib, components | +| `supabase/` | SQL migrations, schema mirror, Edge Functions, CLI config | +| `worker/` | Local ingestion worker (parse, OCR, chunk, embed, DB writes) | +| `scripts/` | CLI ops: reindex, eval, backfill, governance, dev-server helpers | +| `tests/` | Vitest unit (`*.test.ts`) + Playwright E2E (`ui-*.spec.ts`) | +| `docs/` | Runbooks, governance, search/RAG plans, generated sitemap | +| `public/` | Static assets (`public/llms.txt`) | +| `.github/` | CI workflows, PR template (clinical governance preflight) | + +**Do not commit:** `.next/`, `node_modules/`, `coverage/`, `.env*`, `sample-documents/`, logs. + +--- + +## Application architecture + +### Shell and routing + +- **Root layout:** `src/app/layout.tsx` — fonts, `AuthProvider`, global CSS +- **App shell:** `src/app/app-shell-client.tsx` — `GlobalSearchShell` via `src/lib/shell-route-config.ts` +- **Home:** `src/app/page.tsx` — dashboard rendered by shell +- **Dashboard:** `src/components/ClinicalDashboard.tsx` + `src/components/clinical-dashboard/` +- **Modes (8):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, prescribing, tools + +### Product pages (`src/app/`) + +| Route | File | +| ---------------------------------------------------- | -------------------------------------- | +| `/` | `src/app/page.tsx` | +| `/applications` | `src/app/applications/page.tsx` | +| `/differentials`, `/diagnoses`, `/presentations` | `src/app/differentials/` | +| `/documents/search`, `/source`, `/evidence`, `/[id]` | `src/app/documents/` | +| `/favourites` | `src/app/favourites/page.tsx` | +| `/forms`, `/forms/[slug]` | `src/app/forms/` | +| `/medications`, `/medications/[slug]` | `src/app/medications/` | +| `/services`, `/services/[slug]` | `src/app/services/` | +| `/mockups/*` | `src/app/mockups/` (404 in production) | +| `/auth/callback` | `src/app/auth/callback/route.ts` | + +### API routes (`src/app/api/`) + +| Area | Routes | Entry files | +| ----------- | ----------------------------------------------------------------------- | ----------------------------------------------- | +| Answers | `/api/answer`, `/api/answer/stream` | `answer/route.ts`, `answer/stream/route.ts` | +| Search | `/api/search`, `/api/search/interaction` | `search/` | +| Upload | `/api/upload` | `upload/route.ts` | +| Documents | CRUD, bulk, reindex, labels, search, summarize, table-facts, signed-url | `documents/` | +| Ingestion | batches, jobs, retry, quality | `ingestion/` | +| Registry | records CRUD | `registry/records/` | +| Images | signed URLs | `images/[id]/signed-url/route.ts` | +| Ops | health, setup-status, local-project-id | `health/`, `setup-status/`, `local-project-id/` | +| Eval / jobs | eval cases, job state | `eval-cases/`, `jobs/` | + +--- + +## `src/lib/` module map + +### RAG, retrieval, answers + +| Module | Role | +| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| `rag.ts` | Main answer pipeline orchestrator | +| `rag-routing.ts`, `rag-provider.ts`, `rag-answer-text.ts`, `smart-rag-api.ts` | Model routing, provider modes, API surface | +| `clinical-search.ts`, `clinical-query-mode.ts`, `retrieval-selection.ts` | Query modes and retrieval selection | +| `answer-ranking.ts`, `answer-verification.ts`, `answer-formatting.ts`, `answer-follow-up.ts`, `answer-render-policy.ts` | Answer quality and rendering | +| `citations.ts`, `cross-document-synthesis.ts`, `evidence-relevance.ts` | Evidence and synthesis | +| `ranking-config.ts`, `search-scope.ts`, `rag-eval-cases.ts` | Ranking tuning and eval fixtures | + +### Ingestion and indexing + +| Module | Role | +| ----------------------------------------------------------------------- | -------------------------------- | +| `ingestion.ts`, `ingestion-recovery.ts`, `ingestion-mutation-safety.ts` | Job queue semantics and recovery | +| `chunking.ts`, `extractors/document.ts` | Text extraction and chunking | +| `document-index-units.ts`, `document-enrichment.ts`, `deep-memory.ts` | Index artifacts and enrichment | +| `visual-intelligence.ts`, `image-filtering.ts` | Image captioning and filtering | +| `index-quality.ts`, `indexing-coverage.ts`, `model-index-extraction.ts` | Index quality gates | +| `reindex-pipeline.ts`, `reindex-eval-gate.ts`, `bulk-import.ts` | Atomic reindex and bulk import | + +### Source governance and metadata + +| Module | Role | +| ------------------------------------------------------------------------------ | -------------------------------- | +| `source-metadata.ts`, `source-governance.ts`, `source-text-sanitizer.ts` | Source provenance and governance | +| `document-label-governance.ts`, `document-tags.ts`, `document-organization.ts` | Labels and organization | +| `table-review.ts`, `accessible-table-normalization.ts` | Table facts | + +### Supabase, auth, env + +| Module | Role | +| ------------------------------------------------------------------------------------ | ---------------------------- | +| `supabase/client.tsx`, `server.ts`, `admin.ts`, `auth.ts`, `health.ts`, `project.ts` | Clients and auth | +| `supabase/database.types.ts` | Generated DB types | +| `env.ts` | Zod-validated environment | +| `owner-scope.ts`, `query-privacy.ts`, `privacy.ts`, `audit.ts` | Multi-user scope and privacy | + +### Clinical product data + +| Module | Role | +| -------------------------------------------------------------------- | ------------------------- | +| `differentials.ts`, `forms.ts`, `services.ts`, `registry-records.ts` | Registry-backed content | +| `clinical-safety.ts`, `demo-data.ts`, `ui-copy.ts` | Safety copy and demo mode | + +### Infra helpers + +| Module | Role | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `openai.ts`, `embedding-dimensions.ts`, `api-rate-limit.ts` | External APIs and rate limits | +| `validation/` | `body.ts`, `query.ts`, `params.ts`, `http.ts`, `form-data.ts` | +| `shell-route-config.ts`, `document-flow-routes.ts`, `local-project-identity.ts` | Routing and project identity | + +--- + +## Supabase + +### Config and schema + +- **CLI:** `supabase/config.toml` — `indexing-v3-agent` function, `verify_jwt = false` +- **Schema mirror:** `supabase/schema.sql` (reference; migrations are source of truth) +- **Migrations:** `supabase/migrations/*.sql` (~90 files, May–Jul 2026) +- **Drift policy:** `docs/supabase-migration-reconciliation.md` + +### Core tables + +`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `clinical_registry_records`, `api_rate_limits`, `audit_logs`, `storage_cleanup_jobs` + +**Storage buckets:** `clinical-documents`, `clinical-images` (private) + +### Migration themes + +| Theme | Examples | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Bulk ingestion and job queue | `20260527000000_bulk_ingestion.sql`, `20260616001000_ingestion_job_state_rpcs.sql` | +| Hybrid retrieval RPCs | `20260607183245_search_trigram_indexes_and_response_cache.sql`, `20260701140631_codify_live_retrieval_rpcs.sql` | +| Embeddings / HNSW | `20260623014639_finalize_embedding_fields_hnsw_health.sql` | +| Deep memory / visual intelligence | `20260528009000_deep_memory_indexing.sql`, `20260623150000_visual_intelligence_v1.sql` | +| Indexing v3 agent | `20260625000000_indexing_v3_agent_worker_hardening.sql`, `20260702190000_indexing_v3_agent_jobs_table.sql` | +| Atomic reindex | `20260628000000_atomic_reindex_generation_commit.sql` | +| Clinical registry | `20260703020000_clinical_registry_records.sql` | + +### Key RPCs + +- **Jobs:** `claim_ingestion_jobs`, `claim_indexing_v3_agent_jobs` +- **Index lifecycle:** `commit_document_index_generation`, `cleanup_abandoned_document_index_generations` +- **Retrieval:** `match_document_chunks_hybrid`, `match_document_chunks_text`, `match_documents_for_query`, `match_document_table_facts_text`, `match_document_embedding_fields_hybrid`, `match_document_memory_cards_hybrid_v2` +- **Health:** `search_schema_health`, `explain_retrieval_rpc` + +### Edge Functions + +| Function | Path | +| ----------------- | ----------------------------------------------- | +| indexing-v3-agent | `supabase/functions/indexing-v3-agent/index.ts` | + +Cron-triggered agent for indexing v3 completion gates. Auth via `INDEXING_V3_AGENT_SECRET`. Type-checked by `npm run check:edge:functions`. + +--- + +## Worker (`worker/`) + +| File | Role | +| ------------------------------ | ------------------------------------------------------------------------ | +| `index.ts` | Bootstrap → `main.ts` | +| `main.ts` | Polls `ingestion_jobs`, extracts, chunks, embeds, writes index artifacts | +| `embedding-fields.ts` | Additional embedding field inputs | +| `table-facts.ts` | Table fact extraction | +| `prerequisites.ts` | Python/PDF OCR checks | +| `python/extract_pdf_assets.py` | PDF asset extraction (PyMuPDF/Tesseract) | + +**Flow:** Upload → Storage + job queue → worker parses (PDF/DOCX/XLSX/TXT) → OCR fallback → image captioning → chunking → OpenAI embeddings → pgvector. + +**Run:** `npm run worker` or `npm run worker:once` + +--- + +## Scripts (grouped) + +| Group | Key scripts | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Dev/server | `ensure-local-server.mjs`, `dev-free-port.mjs`, `check-runtime.ts` | +| Ingestion/indexing | `import-documents.ts`, `reindex.ts`, `reindex-health.ts`, `check-indexing.ts`, `backfill-smart-index.ts`, `recover-ingestion-queue.ts` | +| Document intelligence | `enrich-documents.ts`, `classify-documents.ts`, `backfill-gold-document-labels.ts` | +| Governance | `audit-source-governance.ts`, `production-readiness.ts`, `check-supabase-project.ts` | +| RAG eval | `eval-rag.ts`, `eval-retrieval.ts`, `eval-quality.ts`, `retrieval-health.ts` | +| Maintenance | `cleanup-storage.ts`, `generate-site-map.ts`, `seed-registry-records.ts` | + +Golden retrieval fixture: `scripts/fixtures/rag-retrieval-golden.json` + +--- + +## Tests + +| Config | Path | +| ---------------- | --------------------------------------------- | +| Unit (Vitest) | `vitest.config.mts` — `tests/**/*.test.ts` | +| E2E (Playwright) | `playwright.config.ts` — `tests/ui-*.spec.ts` | +| Visual E2E | `playwright.visual.config.ts` | + +**Domain clusters in `tests/`:** RAG/answers, retrieval, ingestion/indexing, source governance, API routes, Supabase schema, shell/routing, UI formatting guards. + +**Gates:** `verify:cheap` (lint + typecheck + unit), `verify:ui` (Chromium E2E), `verify:release` (full build + all browsers + production readiness). + +--- + +## Domain concepts + +### Indexing pipeline + +1. Upload via `/api/upload` → `clinical-documents` bucket +2. Queue `ingestion_jobs` (+ optional `import_batches`) +3. **Worker** (`worker/main.ts`) or **Edge agent** (`indexing-v3-agent`) processes: extract → chunk → embed → write chunks, pages, images, embedding fields, index units, table facts +4. Quality gates: `document_index_quality`, enrichment versions, strict completion RPCs +5. Reindex: atomic generation commits (`reindex-pipeline.ts`), abandoned generation recovery + +### RAG + +- Hybrid retrieval: pgvector HNSW + lexical (tsvector/trigram) via Postgres RPCs +- Answer routing: fast vs strong models; `RAG_PROVIDER_MODE` (auto/openai/offline) +- Caching: `rag_response_cache`, app-layer caches in `env.ts` +- Eval: `npm run eval:quality`, `eval:retrieval` + +### Clinical KB surface + +- 8 app modes with unified search shell +- Documents mode: upload/manage private guidelines, search, cited answers +- Answer mode: grounded Q&A with PDF-linked citations +- Registry modes: services, forms, medications, differentials +- Demo mode: synthetic data when Supabase unavailable (`demo-data.ts`, `isDemoMode()` in `env.ts`) + +--- + +## Key config files + +| File | Role | +| ----------------------------------- | ------------------------------------------- | +| `package.json` | Scripts, deps, Node 24 / npm 11 | +| `.env.example` | Full env template | +| `next.config.ts` | CSP, security headers, build config | +| `tsconfig.json` | Strict TS; excludes `supabase/functions/**` | +| `eslint.config.mjs` | Lint scope | +| `AGENTS.md` | Agent rules, verification gates, shortcuts | +| `.github/workflows/ci.yml` | CI pipeline | +| `docs/process-hardening.md` | Verification pyramid | +| `docs/clinical-governance.md` | Clinical safety governance | +| `docs/reindex-runbook.md` | Reindex operations | +| `docs/retrieval-quality-runbook.md` | Retrieval tuning | + +--- + +## Related docs + +| Topic | Doc | +| ----------------------- | --------------------------------------------- | +| Routes and modes | `docs/site-map.md` | +| Search/RAG roadmap | `docs/search-rag-master-plan.md` | +| Reindex operations | `docs/reindex-runbook.md` | +| Production readiness | `docs/production-readiness-checklist.md` | +| Frontend refactor | `docs/frontend-architecture-refactor-plan.md` | +| Repo audit (2026-07-01) | `docs/audit/repo-audit-2026-07-01.md` | + +--- + +_Generated for agent onboarding. Update when adding major modules, API surfaces, or migration themes._ diff --git a/docs/multi-user-auth-setup.md b/docs/multi-user-auth-setup.md index f413eb015..4bd643e1a 100644 --- a/docs/multi-user-auth-setup.md +++ b/docs/multi-user-auth-setup.md @@ -1,13 +1,13 @@ # Multi-user auth — Supabase configuration checklist (you apply) -The **code** for multi-user (persistent cookie sessions, magic link + password + -SSO, per-user isolation) lands via the `claude/multiuser-auth` branch. The -**live Supabase configuration** below is done by you in the dashboard / provider -consoles — Claude does not change the live Auth config. Target project: -`Clinical KB Database` (`sjrfecxgysukkwxsowpy`). - -> **Order matters:** do **not** enable open signup on live until the fail-closed -> owner-scoping hardening on this branch has merged (the DB owner-RLS + private +The app ships multi-user auth code (persistent cookie sessions, magic link + +password + SSO, per-user isolation). The **live Supabase configuration** below +is done by you in the dashboard / provider consoles — it is operator-owned, not +changed automatically by repo commits. Target project: `Clinical KB Database` +(`sjrfecxgysukkwxsowpy`). + +> **Order matters:** do **not** enable open signup on live until fail-closed +> owner-scoping hardening has merged and been verified (the DB owner-RLS + private > storage backstop is already in place — see §7). Validate the whole flow in a > **staging** project first. @@ -52,15 +52,14 @@ consoles — Claude does not change the live Auth config. Target project: ## 6. App environment variables -Already used by the app; ensure they are set per environment. Concrete values -for **this** project (retrieved read-only from the live project 2026-07-03): +Already used by the app; ensure they are set per environment. Copy values from +the Supabase dashboard → Project Settings → API: - `NEXT_PUBLIC_SUPABASE_URL` = `https://sjrfecxgysukkwxsowpy.supabase.co` -- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` = `sb_publishable_TgAfWIQDozYC_reOI-d5cw_FLYPnqOa` - (modern publishable key — public by design, safe in the browser; a legacy anon - JWT is also still active for compatibility) -- `SUPABASE_SERVICE_ROLE_KEY` (server-only; never exposed to the client — not - reproduced here; copy it from the Supabase dashboard → Project Settings → API) +- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` = your publishable or anon key (public + by design, safe in the browser) +- `SUPABASE_SERVICE_ROLE_KEY` (server-only; never exposed to the client — copy + from the dashboard; do not commit real values to docs or Git) The **Supabase OAuth callback** to authorize in the Google Cloud / Azure AD app registrations (§1) is `https://sjrfecxgysukkwxsowpy.supabase.co/auth/v1/callback`. @@ -83,7 +82,7 @@ project**, so no broad RLS migration is required: client storage access is enabled (so no per-user folder policy is needed unless client-direct storage reads are ever added). -Combined with the app-layer **fail-closed owner scoping** shipped on this branch, +Combined with the app-layer **fail-closed owner scoping** in the codebase, per-user isolation is enforced at both layers. **Two residual, low-priority items (out of scope for multi-user, no action needed diff --git a/docs/process-hardening.md b/docs/process-hardening.md index e7da421f5..48c530e9b 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -4,10 +4,10 @@ This document turns the current process review into phased, durable repo practic ## Phase 1 - Active now -- `npm run verify:cheap` is the default broad local gate for source/config/test changes: lint, typecheck, and unit tests. -- `npm run verify:ui` is the default UI gate: Chromium Playwright smoke, stress, and accessibility media checks. -- `npm run verify:release` is the release-confidence gate: lint, typecheck, unit tests, build, and the full Playwright browser project set. -- CI now installs Chromium and runs the Chromium UI gate after build on all branches; a gated release-browser job runs the full Playwright browser matrix on `main`, `release/*`, manual dispatch, and the weekly schedule. +- `npm run verify:cheap` is the default broad local gate for source/config/test changes: `check:runtime`, `sitemap:check`, lint, typecheck, and unit tests. +- `npm run verify:ui` is the default UI gate: `check:runtime` plus Chromium Playwright smoke, stress, and accessibility media checks (`test:e2e:chromium`). +- `npm run verify:release` is the release-confidence gate: `check:runtime`, lint, typecheck, unit tests, build, full Playwright browser matrix, `check:production-readiness`, `governance:release`, and `eval:quality:release` (the last step needs live Supabase and OpenAI keys). +- CI runs two parallel PR jobs: `verify` (runtime alignment, edge-function typecheck, CI-safe production readiness, `format:check`, lint, typecheck, unit tests with coverage, build) and `ui-smoke` (Chromium Playwright against its own dev server). A gated `release-browser-matrix` job runs the full Playwright browser set on `main`, `release/*`, manual dispatch, and the weekly schedule. - `tests/ui-accessibility.spec.ts` covers reduced-motion and forced-colors dashboard usability so those modes are no longer only reviewed by inspection. - `tests/ui-tools.spec.ts` covers the Applications dashboard mode at mobile and desktop sizes, including the `/applications` compatibility redirect. - `AGENTS.md` now points future agents to these gates and to this document. @@ -18,6 +18,7 @@ This document turns the current process review into phased, durable repo practic - Local scratch and visual-capture output are excluded from Prettier through `.prettierignore` so generated investigation files do not block the format gate. - Pull requests now include a clinical governance preflight for ingestion, answer generation, source rendering, privacy, production environment, and clinical-output changes. - Applications mode now has dedicated Playwright coverage in the UI gate. +- `tests/ui-stress.spec.ts` uses a **1000px** desktop viewport (below `lg`) because scope and evidence open in sheets, not side panels, at that width; widening to ≥1024 exercises a different UI path. ## Phase 3 - Structural cleanup @@ -40,6 +41,8 @@ This document turns the current process review into phased, durable repo practic - **2026-07-03:** extracted `document-results.tsx` — `WhyThisMatchedPanel`, `RelatedDocumentsPanel`, `StagedAnswerResultSurface` (contiguous block 456–944, moved verbatim). Monolith 5216 → 4726 lines. **No runtime cycle** — the only monolith import is `type AnswerFeedbackType` (erased); `StagedAnswerResultSurface` is a leaf result-surface that imports one-way from every sibling module (answer-content, evidence-panels, visual-evidence, relevance, document-search-results, badges, dashboard-shell, display-text, use-mobile-preview-sheet) and the monolith imports `RelatedDocumentsPanel` + `StagedAnswerResultSurface` back. The monolith's `visual-evidence` import (`InlineTableCard`/`MobileEvidenceSheetContent`) moved into `document-results` as predicted. Added `document-results.tsx` to the `rendered-text-formatting.test.ts` corpus; stripped 35 now-orphaned monolith imports. +- **2026-07-04:** answer-review hygiene — `StagedAnswerResultSurface` now lives in `answer-result-surface.tsx`; `document-results.tsx` re-exports it and owns `RelatedDocumentsPanel` (monolith imports both). `AnswerFeedbackType` moved to `lib/answer-feedback.ts` so `evidence-panels`, `visual-evidence`, and `answer-result-surface` no longer import types from `ClinicalDashboard`. `rendered-text-formatting.test.ts` corpus includes `answer-result-surface.tsx`. Clinical-note checklist rows link to primary sources when available. + #### Decomposition COMPLETE (approved move map) All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went from ~8.8k → ~4.7k lines and now holds the main `ClinicalDashboard` orchestrator, its data/state hooks, and the deferred admin surfaces only. The 6 extracted modules live in `src/components/clinical-dashboard/`: `auth-panel`, `answer-content`, `evidence-panels`, `output-panel`, `visual-evidence`, `document-results` (+ the shared `use-mobile-preview-sheet` hook and `display-text` helpers). The barrel `index.ts` was intentionally not extended. diff --git a/docs/production-readiness-checklist.md b/docs/production-readiness-checklist.md index a0505bda0..e38e98c7f 100644 --- a/docs/production-readiness-checklist.md +++ b/docs/production-readiness-checklist.md @@ -2,7 +2,8 @@ This is the runbook to make the app publishable in one focused pass. -- Branch: `codex/premium-redesign` (do not touch `.env` / secrets directly). +Last reviewed: 2026-07-04. Applies to any feature branch or release candidate. + - Runtime target: Next.js 16.2.9, Node 24.x, npm 11.x. - Supabase target: `sjrfecxgysukkwxsowpy` (`Clinical KB Database`). diff --git a/docs/redesign/06-verification.md b/docs/redesign/06-verification.md index 01fa3376a..1963e82f4 100644 --- a/docs/redesign/06-verification.md +++ b/docs/redesign/06-verification.md @@ -4,7 +4,7 @@ Scope: ultra-premium mobile-first redesign — token system, component layer, da ## June 20 scoped run — dashboard/viewer only, Tools deferred -Branch: `codex/premium-redesign`. Local server: `npm run ensure` confirmed `http://localhost:4298`; `/api/local-project-id` confirmed `Clinical KB` with `safeLocalOrigin: true`. +Last reviewed: 2026-07-04. Historical verification log for the premium-redesign reconciliation pass; branch names and test counts are snapshots only. | Check | Command | Result | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/docs/site-map.md b/docs/site-map.md index 33f3ad27e..6da797114 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -9,17 +9,18 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. - `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. +- `/documents/search` - Documents search command centre. Source: `src/app/documents/search/page.tsx`. +- `/documents/source` - Master document reader with demo PDF content and evidence navigation. Source: `src/app/documents/source/page.tsx`. +- `/documents/source/evidence` - Evidence detail page for document flow. Source: `src/app/documents/source/evidence/page.tsx`. - `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. -- `/favourites/legacy` - Route discovered from app directory Source: `src/app/favourites/legacy/page.tsx`. - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. - `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. -- `/services-navigator-preview` - Route discovered from app directory Source: `src/app/services-navigator-preview/page.tsx`. ## Mode/query routes - `/?mode=answer` - Answer mode. Search kind: `answer`. Query example: `/?mode=answer&q=example+question&focus=1&run=1`. -- `/?mode=documents` - Documents mode. Search kind: `documents`. Query example: `/?mode=documents&q=lithium+monitoring&focus=1&run=1`. +- `/?mode=documents` - Documents mode. Search kind: `documents`. Query example: `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1`. - `/services` - Services mode. Search kind: `services`. Query example: `/services?q=13YARN&focus=1&run=1`. - `/forms` - Forms mode. Search kind: `documents`. Query example: `/forms?q=transport+forms&focus=1&run=1`. - `/favourites` - Favourites mode. Search kind: `favourites`. Query example: `/favourites?q=clozapine+set&focus=1&run=1`. @@ -29,16 +30,24 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## Mode page index -| Mode | Home page | Search/results page | Information/detail pages | -| ------------- | -------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | -| Documents | `/?mode=documents` | `/?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/[id]` document viewer and in-document search. | -| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | -| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | -| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | -| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | -| Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | -| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | `/applications` launcher and tool detail panels inside tools mode. | +| Mode | Home page | Search/results page | Information/detail pages | +| ------------- | -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | +| Documents | `/?mode=documents` | `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/source` master reader, `/documents/source/evidence` evidence detail, `/documents/search` results, and `/documents/[id]` live viewer. | +| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | +| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | +| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | +| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | +| Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | +| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | `/applications` launcher and tool detail panels inside tools mode. | + +## Documents flow index + +- `/?mode=documents` - Documents mode home. Stays as the no-query home surface for document mode. +- `/documents/search?mode=documents&q=clozapine+monitoring+table&focus=1&run=1` - Documents search command centre used after submitting a search in Documents mode. +- `/documents/source?mode=documents&document=clozapine-monitoring&q=clozapine+monitoring+table&page=12&chunk=monitoring-table` - Standalone master document reader for a selected result, with bundled demo PDF content and evidence navigation. +- `/documents/source/evidence?mode=documents&document=clozapine-monitoring&evidence=monitoring-table&q=clozapine+monitoring+table&page=12&chunk=monitoring-table` - Reusable evidence detail page opened from the search tray or document reader evidence cards. +- `/documents/[id]` - Live document viewer route remains available for real document records. ## Registry-backed routes @@ -93,10 +102,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/answer-evidence-popups` - Route discovered from app directory Source: `src/app/mockups/answer-evidence-popups/page.tsx`. - `/mockups/document-search` - Route discovered from app directory Source: `src/app/mockups/document-search/page.tsx`. -- `/mockups/document-search-command` - Route discovered from app directory Source: `src/app/mockups/document-search-command/page.tsx`. - `/mockups/document-search-evidence-lens` - Route discovered from app directory Source: `src/app/mockups/document-search-evidence-lens/page.tsx`. - `/mockups/document-search-triage-board` - Route discovered from app directory Source: `src/app/mockups/document-search-triage-board/page.tsx`. -- `/mockups/document-search/source` - Route discovered from app directory Source: `src/app/mockups/document-search/source/page.tsx`. - `/mockups/document-search/source-overlays` - Route discovered from app directory Source: `src/app/mockups/document-search/source-overlays/page.tsx`. - `/mockups/document-search/source/evidence` - Route discovered from app directory Source: `src/app/mockups/document-search/source/evidence/page.tsx`. - `/mockups/favourites-command-console` - Route discovered from app directory Source: `src/app/mockups/favourites-command-console/page.tsx`. @@ -121,11 +128,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/tools-split-safety-deck` - Route discovered from app directory Source: `src/app/mockups/tools-split-safety-deck/page.tsx`. - `/mockups/tools-task-directory` - Route discovered from app directory Source: `src/app/mockups/tools-task-directory/page.tsx`. - `/mockups/tools-workflow-board` - Route discovered from app directory Source: `src/app/mockups/tools-workflow-board/page.tsx`. - -### Non-routed mockup artifacts - -- `mockups/answer-evidence-popups/page.tsx` - Root-level mockup artifact outside `src/app`; not a Next route. -- `mockups/medication-prescribing/page.tsx` - Root-level mockup artifact outside `src/app`; not a Next route. +- `/mockups/universal-search-command` - Route discovered from app directory Source: `src/app/mockups/universal-search-command/page.tsx`. +- `/mockups/universal-search-redesign` - Route discovered from app directory Source: `src/app/mockups/universal-search-redesign/page.tsx`. ## API routes @@ -166,7 +170,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## Known caveats and stale-path flags -- No active stale internal route targets are expected in the current generated sitemap. +- `/mockups/*` prototype routes are development-only; production returns 404 and `robots.txt` disallows indexing. - `/mockups/favourites-hub` is a legacy compatibility route and should redirect to `/favourites`. - Registry-backed service and form pages may show sign-in, load-error, or in-app not-found states for missing per-user records. - Live user registries may contain additional service or form slugs beyond the seeded/demo slugs listed here. @@ -184,6 +188,6 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Favourites | `src/app/favourites, src/components/clinical-dashboard/favourites-home-page.tsx` | | Differentials | `src/app/differentials, src/lib/differentials.ts` | | Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | -| Documents | `src/app/documents/[id], src/app/api/documents` | +| Documents | `src/app/documents, src/lib/document-flow-routes.ts` | | Applications and tools | `src/app/applications, src/components/applications-launcher-page.tsx` | -| Mockups | `src/app/mockups, mockups/` | +| Mockups | `src/app/mockups` | diff --git a/docs/supabase-migration-reconciliation.md b/docs/supabase-migration-reconciliation.md index 1fd3b2899..cddf96f42 100644 --- a/docs/supabase-migration-reconciliation.md +++ b/docs/supabase-migration-reconciliation.md @@ -1,18 +1,20 @@ # Supabase Migration Reconciliation -Last reviewed: 2026-06-28 +Last reviewed: 2026-07-04 Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`) ## Policy - Do not use `supabase db push` while local and remote migration history are divergent. +- **Never change a retrieval RPC, index, or function on the live project with raw SQL in the dashboard.** Use a committed migration under `supabase/migrations/` and reconcile `supabase/schema.sql` in the same change. - 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. +- Leave other local-only migrations unrepaired until their effects are verified or deliberately applied. +- Run `npx supabase migration list --linked` at apply/reconcile time; do not rely on a frozen “aligned through” snapshot in this doc alone. -## Verified Applied +## Verified Applied (through June 2026) -These previously local-only versions have been verified in the live project history: +These previously local-only versions were verified in the live project history before the July 2026 reconciliation wave: - `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. @@ -23,9 +25,23 @@ These previously local-only versions have been verified in the live project hist - `20260628000000` - atomic document index generation commit RPC and committed-generation retrieval filters are present and verified in live. - `20260628135727` - explicit `invoke_indexing_v3_agent(integer)` execute grant hardening is present and verified in live. -## Current Status +## Current Status (July 2026) -As of this review, `npx supabase migration list --linked` shows no local-only migrations for `sjrfecxgysukkwxsowpy`. Remote migration history is aligned through `20260628135727`. +The repo now includes additional July 2026 migrations beyond the June checkpoint above, including: + +- Retrieval RPC codification and hybrid execution smoke (`20260701140631`, related July 1 fixes) +- Legacy vector index drops and `search_schema_health()` reconciliation (`20260702014803`, `20260702021604`) +- Clinical registry tables (`20260703020000`) +- Storage cleanup index reconciliation prep (`20260703030000`, prepared but apply only with explicit approval) +- Indexing v3 agent job table and related hardening (`20260702190000` and neighbors) + +Live-only drift, duplicate migration-version churn, and outstanding follow-up debts are tracked in the **Retrieval RPC drift & indexing hygiene** section of [`docs/process-hardening.md`](process-hardening.md). Treat that section as the operational supplement to this reconciliation doc. + +Before applying pending migrations to live: + +1. Run `npx supabase migration list --linked` and confirm local vs remote alignment. +2. Run `npm run supabase:recovery-status` and confirm Supabase is healthy. +3. Apply only through the normal migration workflow; update `supabase/schema.sql` when the migration changes canonical schema shape. ## Verification Commands @@ -34,4 +50,5 @@ 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;" npx supabase db query --linked "select to_regprocedure('public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb)') as commit_generation_rpc, has_function_privilege('anon', 'public.invoke_indexing_v3_agent(integer)', 'execute') as anon_can_invoke_indexing_v3_agent, has_function_privilege('service_role', 'public.invoke_indexing_v3_agent(integer)', 'execute') as service_role_can_invoke_indexing_v3_agent;" +npm run check:indexing ``` diff --git a/mockups/README.md b/mockups/README.md index 5dfb6b4e9..5c9ad9a06 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -1,59 +1,23 @@ # Project Mockups -This folder collects the current mockup files for the Clinical KB Database project in one place. +This folder collects notes for mockup routes that live under `src/app/mockups/`. -All remaining mockups use the Clinical White / Aegean Graphite role tokens (`--command`, `--clinical-accent`, `--success`) -from `docs/redesign/02-design-direction.md`. The design-exploration mockups that led to that theme served their purpose and -were removed in July 2026 so stale palettes do not mislead future design review (`answer-best-layout`, -`clinical-command-popup`, `compact-answer-entry-points`, `crisp-white-colour-system`, `evidence-option`, -`evidence-redesign`, `extended-menu-refined`, `final-rag-structure`, `premium-colour-system`, `rag-answer-responsive`, -`rag-answer-structure`, `safety-critical-redesign`, `safety-notes-triage-redesign`). +## Authoritative route list -## Included mockups +The generated route map in [`docs/site-map.md`](../docs/site-map.md) (mockups section) is the source of truth for runnable mockup URLs. Regenerate it after adding or removing mockup routes: -- Medication prescribing now lives in the app at `/?mode=prescribing` and `/medications/acamprosate`. -- `answer-evidence-popups/page.tsx` - copied from `src/app/mockups/answer-evidence-popups/page.tsx` -- `document-search` - runnable document-search mockup review board, in `src/app/mockups/document-search/page.tsx` -- `document-search/source` - live handoff route that resolves a mock result into `/documents/{id}?page=...&chunk=...`, in `src/app/mockups/document-search/source/page.tsx` -- `document-search-command` - runnable mockup only, in `src/app/mockups/document-search-command/page.tsx` -- `document-search-evidence-lens` - runnable mockup only, in `src/app/mockups/document-search-evidence-lens/page.tsx` -- `document-search-triage-board` - runnable mockup only, in `src/app/mockups/document-search-triage-board/page.tsx` -- `mode-dropdown` - runnable mockup only, in `src/app/mockups/mode-dropdown/page.tsx` -- `recent-searches-bottom` - runnable mockup only, in `src/app/mockups/recent-searches-bottom/page.tsx` -- `settings-search-general` - runnable mockup only, in `src/app/mockups/settings-search-general/page.tsx` -- `settings-search-clinical` - runnable mockup only, in `src/app/mockups/settings-search-clinical/page.tsx` -- `settings-search-privacy` - runnable mockup only, in `src/app/mockups/settings-search-privacy/page.tsx` -- `favourites-command-desk` - runnable mockup only, in `src/app/mockups/favourites-command-desk/page.tsx` -- `favourites-set-board` - runnable mockup only, in `src/app/mockups/favourites-set-board/page.tsx` -- `favourites-library-view` - runnable mockup only, in `src/app/mockups/favourites-library-view/page.tsx` +```bash +npm run sitemap:update +npm run sitemap:check +``` -## App routes +## Design tokens -The runnable versions remain in the Next.js app route tree: - -- `/?mode=prescribing` -- `/medications/acamprosate` -- `/mockups/answer-evidence-popups` -- `/mockups/document-search?mode=documents` -- `/mockups/document-search/source?mode=documents&document=clozapine-monitoring&q=clozapine%20monitoring%20table&page=12&chunk=monitoring-table` -- `/mockups/document-search-command?mode=documents` -- `/mockups/document-search-evidence-lens?mode=documents` -- `/mockups/document-search-triage-board?mode=documents` -- `/mockups/mode-dropdown` -- `/mockups/recent-searches-bottom` -- `/mockups/settings-search-general` -- `/mockups/settings-search-clinical` -- `/mockups/settings-search-privacy` -- `/mockups/favourites-command-desk` -- `/mockups/favourites-set-board` -- `/mockups/favourites-library-view` - -Favourites now lives in the live dashboard flow at `/?mode=favourites`; `/mockups/favourites-hub` redirects there for old links. +Mockups use the Clinical White / Aegean Graphite role tokens (`--command`, `--clinical-accent`, `--success`) from [`docs/redesign/02-design-direction.md`](../docs/redesign/02-design-direction.md). Older design-exploration mockups were removed in July 2026 so stale palettes do not mislead future design review. ## Global search shell -New runnable mockups under `src/app/mockups/*` inherit the shared Clinical KB header and bottom search composer from -`src/app/mockups/layout.tsx`. +Runnable mockups under `src/app/mockups/*` inherit the shared Clinical KB header and bottom search composer from `src/app/mockups/layout.tsx`. - Put the mockup content between the global header and bottom composer; do not copy the header or composer into new pages. - Tool and favourites mockups keep the shared app header but hide the bottom composer because they provide their own primary search surface. @@ -61,11 +25,14 @@ New runnable mockups under `src/app/mockups/*` inherit the shared Clinical KB he - The bottom composer routes live searches to the dashboard with `mode`, `q`, and `run=1`; New chat routes to `/?mode=answer&focus=1`. - If a future mockup must be standalone, move it outside the `/mockups` route shell or add an explicit opt-out route group before implementing it. +## Production behavior + +- `/mockups/*` prototype routes are development-only; production returns 404 and `robots.txt` disallows indexing. +- `/mockups/favourites-hub` is a legacy compatibility route and redirects to `/favourites`. +- `/mockups/medication-prescribing` redirects to `/medications/acamprosate`; prescribing mode also lives at `/?mode=prescribing`. + ## Synthetic document-search assets -The document-search mockups use generated non-patient bitmap assets in `public/mockups/document-search/`. These images are -abstract UI/document textures only: they must not be treated as source screenshots, hospital-branded material, or clinical -content. +The document-search mockups use generated non-patient bitmap assets in `public/mockups/document-search/`. These images are abstract UI/document textures only: they must not be treated as source screenshots, hospital-branded material, or clinical content. -The `document-search/source` route is the exception to the fixture-only mockup behavior: it is a local live handoff that -finds an indexed document and opens the existing document viewer with a selected page and chunk. +Some document-search mockups include live handoff routes (for example `document-search/source-overlays`) that resolve into the real document viewer with a selected page and chunk when indexed data is available locally. diff --git a/mockups/answer-evidence-popups/page.tsx b/mockups/answer-evidence-popups/page.tsx deleted file mode 100644 index 8fce521c1..000000000 --- a/mockups/answer-evidence-popups/page.tsx +++ /dev/null @@ -1,734 +0,0 @@ -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-accent)]"; - -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/medication-prescribing/page.tsx b/mockups/medication-prescribing/page.tsx deleted file mode 100644 index c2c1b9b05..000000000 --- a/mockups/medication-prescribing/page.tsx +++ /dev/null @@ -1,710 +0,0 @@ -import { - Activity, - AlertTriangle, - ArrowLeftRight, - BadgeCheck, - Brain, - CalendarDays, - ChartNoAxesColumnIncreasing, - CheckCircle2, - ChevronDown, - ChevronRight, - ClipboardCheck, - ClipboardList, - Droplet, - FileText, - FlaskConical, - Lock, - Mic, - Paperclip, - Pill, - Plus, - Send, - ShieldCheck, - UserRound, - type LucideIcon, -} from "lucide-react"; - -type LedgerColumn = { - label: string; - value: string; - meta?: string; -}; - -type LedgerRowData = { - label: string; - icon: LucideIcon; - body?: string | readonly string[]; - columns?: readonly LedgerColumn[]; - tone?: "danger"; - compact?: boolean; -}; - -const badges = ["333 mg EC tablet", "PBS streamlined", "Reviewed"] as const; - -const decisionTiles = [ - { - label: "Prescribing answer", - value: "Maintenance after detox", - meta: "with psychosocial support", - icon: CheckCircle2, - tone: "teal", - }, - { - label: "Dosing", - value: "666 mg TID", - meta: "2 x 333 mg", - icon: CalendarDays, - tone: "teal", - }, - { - label: "Dose ceiling", - value: "1,998 mg/day", - meta: "MAX", - icon: ChartNoAxesColumnIncreasing, - tone: "teal", - }, - { - label: "Avoid", - value: "Cr >120", - meta: "micromol/L", - icon: AlertTriangle, - tone: "danger", - }, -] as const; - -const ledgerRows: readonly LedgerRowData[] = [ - { - label: "Prescribing answer", - icon: ClipboardList, - body: [ - "Use for maintenance of abstinence after detox when renal function is acceptable.", - "Start after withdrawal and continue if relapse occurs. Not for use in acute withdrawal.", - ], - }, - { - label: "Dosing", - icon: CalendarDays, - columns: [ - { label: "Usual dose", value: "666 mg (2 x 333 mg) TID with meals" }, - { label: "Dose ceiling", value: "1,998 mg/day", meta: "MAX" }, - { label: "Under 60 kg", value: "2 tablets morning, 1 midday, 1 night" }, - { label: "Treatment duration", value: "Around 1 year" }, - ], - }, - { - label: "Administration", - icon: Pill, - body: ["Take with food. Swallow EC tablets whole with water.", "Do not crush or chew."], - }, - { - label: "Do not use", - icon: AlertTriangle, - tone: "danger", - body: [ - "Renal insufficiency: serum creatinine >120 micromol/L (contraindicated)", - "Severe hepatic failure (Child-Pugh C) (contraindicated)", - "Pregnancy (DO NOT USE)", - "Breastfeeding (DO NOT USE)", - ], - }, - { - label: "Populations", - icon: UserRound, - body: "Avoid in children/adolescents under 18 years and in adults over 65 years: safety and efficacy not established.", - }, - { - label: "Key risks", - icon: ShieldCheck, - columns: [ - { label: "GI", value: "Diarrhea, nausea, flatulence (high)" }, - { label: "Dermatologic", value: "Rash, pruritus" }, - { label: "Neuropsychiatric", value: "Mood swings, depression" }, - ], - }, - { - label: "Pearls / PK", - icon: FlaskConical, - compact: true, - body: "Mechanism not fully established - Not metabolized; excreted unchanged in urine - Half-life 13-28.4 h - Minimal protein binding", - }, -] as const; - -const monitoringRows = [ - { label: "Renal", body: "Check baseline and periodically", icon: Droplet }, - { label: "Hepatic (severe disease)", body: "Assess if severe liver disease suspected", icon: ShieldCheck }, - { label: "Mood / suicidality", body: "Monitor, especially early treatment", icon: Brain }, - { label: "Adherence", body: "Reinforce adherence and support", icon: ClipboardCheck }, -] as const; - -const interactionRows = [ - "Diazepam, disulfiram, imipramine: no major PK interactions.", - "Naltrexone: increases acamprosate exposure; no dose adjustment required.", - "Other psychotropics: not well studied.", -] as const; - -const mobileRows = [ - { - label: "Prescribing answer", - icon: ClipboardList, - body: "Use for maintenance after detox when renal function is acceptable. Start after withdrawal and continue if relapse occurs.", - }, - { - label: "Dosing", - icon: CalendarDays, - body: "Usual: 666 mg (2 x 333 mg) TID with meals. Max: 1,998 mg/day. <60 kg: 2 tabs morning, 1 midday, 1 night. Duration: around 1 year.", - }, - { - label: "Administration", - icon: Pill, - body: "Take with food. Swallow EC tablets whole with water. Do not crush or chew.", - }, - { - label: "Do not use", - icon: AlertTriangle, - body: "Renal insufficiency (Cr >120), severe hepatic failure (Child-Pugh C), pregnancy, breastfeeding.", - tone: "danger", - }, - { - label: "Populations", - icon: UserRound, - body: "Avoid <18 years and >65 years: safety and efficacy not established.", - }, -] as const; - -function IconFrame({ - icon: Icon, - tone = "teal", - className = "h-9 w-9", -}: { - icon: LucideIcon; - tone?: "teal" | "danger" | "slate"; - className?: string; -}) { - return ( - - - ); -} - -function TopNav({ compact = false }: { compact?: boolean }) { - return ( -
-

- Clinical KB -

- -
- ); -} - -function MedicationBadges({ compact = false }: { compact?: boolean }) { - return ( -
- {badges.map((badge, index) => ( - - {index === 2 ? - ))} -
- ); -} - -function MedicationHeader({ compact = false }: { compact?: boolean }) { - if (compact) { - return ( -
-
- -
-

Acamprosate

-

- Alcohol abstinence maintenance · GABA / - glutamate modulator -

-
-
- -
- ); - } - - return ( -
- -
-

Acamprosate

-

- Alcohol abstinence maintenance · GABA / glutamate - modulator -

- -
-
- ); -} - -function DecisionTile({ tile, compact = false }: { tile: (typeof decisionTiles)[number]; compact?: boolean }) { - const danger = tile.tone === "danger"; - const compactBody = - tile.label === "Prescribing answer" || tile.label === "Avoid" ? `${tile.value} ${tile.meta}` : tile.value; - const showMeta = !compact || (tile.label !== "Prescribing answer" && tile.label !== "Avoid"); - - return ( -
-
- -
-

- {tile.label} -

-

- {compact ? compactBody : tile.value} -

- {showMeta ? ( -

- {tile.meta} -

- ) : null} -
-
-
- ); -} - -function normalizeBody(body: string | readonly string[] | undefined): readonly string[] { - if (!body) { - return []; - } - - return typeof body === "string" ? [body] : body; -} - -function LedgerRow({ row }: { row: (typeof ledgerRows)[number] }) { - const Icon = row.icon; - const danger = row.tone === "danger"; - const body = normalizeBody(row.body); - const columns = row.columns; - - return ( -
-
- -

- {row.label} -

-
-
- {columns ? ( -
- {columns.map((column, index) => ( -
-

{column.label}

-

{column.value}

- {"meta" in column && column.meta ? ( -

- {column.meta} -

- ) : null} -
- ))} -
- ) : danger ? ( -
    - {body.map((item) => ( -
  • -
  • - ))} -
- ) : ( -
-
- {body.map((item) => ( -

{item}

- ))} -
- {row.compact ? ( -
- )} -
-
- ); -} - -function MonitoringPanel({ compact = false }: { compact?: boolean }) { - return ( -
-
-
-
- {monitoringRows.map((item) => ( -
- -
-

- {item.label} -

-

- {item.body} -

-
-
- ))} -
-
- ); -} - -function InteractionsPanel() { - return ( -
-
-
-
    - {interactionRows.map((item) => ( -
  • - - {item} -
  • - ))} -
-
- ); -} - -function AccessPanel() { - return ( -
-
-
-
- {[ - ["Brand", "Campral"], - ["PBS status", "PBS streamlined"], - ["PBS item", "8357W"], - ].map(([label, value], index) => ( -
-
{label}
-
{value}
-
- ))} -
-
- ); -} - -function SourcesRow({ compact = false }: { compact?: boolean }) { - return ( -
-
-
-
- ); -} - -function DesktopComposer() { - return ( -
-
- -
- Ask follow-up or add context... -
-
-
- ); -} - -function DesktopMockup() { - return ( -
- -
-
- -
- {decisionTiles.map((tile) => ( - - ))} -
-
- {ledgerRows.map((row) => ( - - ))} -
- -
- -
-
- ); -} - -function MobileTabs() { - return ( -
- {["Summary", "Dosing", "Safety", "More"].map((tab, index) => ( - - {tab} - - ))} -
- ); -} - -function MobileRow({ row }: { row: (typeof mobileRows)[number] }) { - const danger = "tone" in row && row.tone === "danger"; - return ( -
- -
-
-

- {row.label} -

-
-

{row.body}

-
-
- ); -} - -function MobileCollapsedRow({ label, icon: Icon }: { label: string; icon: LucideIcon }) { - return ( -
- - -
- ); -} - -function MobileComposer() { - return ( -
-
- -
- Ask follow-up... -
-
-
- ); -} - -function PhoneMockup() { - return ( - - ); -} - -export default function MedicationPrescribingMockupsPage() { - return ( -
-
- - -
-
- ); -} diff --git a/public/llms.txt b/public/llms.txt index 6bbb514b6..c2ec0686b 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -2,8 +2,11 @@ Clinical Guide Purpose: Clinical Guide is a local clinical knowledge-base interface for searching indexed source documents, reviewing evidence, and drafting source-backed clinical answers. +Agent / codebase orientation: docs/codebase-index.md (module map, APIs, Supabase, worker). Route index: docs/site-map.md. + Key routes: - / opens the main dashboard. Use ?mode=answer, ?mode=documents, ?mode=tools, ?mode=favourites, ?mode=differentials, or ?mode=prescribing to choose the workspace. +- /documents/search opens the documents search command centre after submitting a documents-mode query. - /documents/:id opens an indexed source document. - /services opens source-backed service records. - /forms opens source-backed forms. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 2c5afe126..115c74068 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -4,6 +4,12 @@ import { pathToFileURL } from "node:url"; import { format } from "prettier"; import { appModeDefinitions, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { + documentEvidenceHref, + documentReaderHref, + documentsSearchHref, + DOCUMENTS_MODE_HOME_ROUTE, +} from "@/lib/document-flow-routes"; import { differentialRecords } from "@/lib/differentials"; import { formRecords } from "@/lib/forms"; import { serviceRecords } from "@/lib/services"; @@ -40,6 +46,9 @@ const routeDescriptions: Record = { "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", "/differentials/presentations": "Presentation workflow stream.", "/documents/[id]": "Document viewer/detail page.", + "/documents/search": "Documents search command centre.", + "/documents/source": "Master document reader with demo PDF content and evidence navigation.", + "/documents/source/evidence": "Evidence detail page for document flow.", "/favourites": "Saved clinical items and sets.", "/forms": "Forms home and search surface.", "/forms/[slug]": "Registry-backed form detail.", @@ -87,9 +96,9 @@ const routeOwnershipRows = [ ["Favourites", "src/app/favourites, src/components/clinical-dashboard/favourites-home-page.tsx"], ["Differentials", "src/app/differentials, src/lib/differentials.ts"], ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], - ["Documents", "src/app/documents/[id], src/app/api/documents"], + ["Documents", "src/app/documents, src/lib/document-flow-routes.ts"], ["Applications and tools", "src/app/applications, src/components/applications-launcher-page.tsx"], - ["Mockups", "src/app/mockups, mockups/"], + ["Mockups", "src/app/mockups"], ] as const; function toPosixPath(value: string) { @@ -189,7 +198,7 @@ function renderSlugInventory(title: string, routePattern: string, slugs: readonl function renderModeRoutes() { const examples: Record = { answer: appModeHomeHref("answer", { query: "example question", focus: true, run: true }), - documents: appModeHomeHref("documents", { query: "lithium monitoring", focus: true, run: true }), + documents: documentsSearchHref({ query: "lithium monitoring", focus: true, run: true }), services: appModeHomeHref("services", { query: "13YARN", focus: true, run: true }), forms: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), favourites: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), @@ -231,9 +240,10 @@ function renderModePageIndex() { }, { mode: "Documents", - home: appModeHomeHref("documents"), - search: appModeHomeHref("documents", { query: "lithium monitoring", focus: true, run: true }), - detail: "`/documents/[id]` document viewer and in-document search.", + home: DOCUMENTS_MODE_HOME_ROUTE, + search: documentsSearchHref({ query: "lithium monitoring", focus: true, run: true }), + detail: + "`/documents/source` master reader, `/documents/source/evidence` evidence detail, `/documents/search` results, and `/documents/[id]` live viewer.", }, { mode: "Services", @@ -274,6 +284,36 @@ function renderModePageIndex() { ]); } +function renderDocumentFlowIndex() { + return [ + bullet(DOCUMENTS_MODE_HOME_ROUTE, "Documents mode home. Stays as the no-query home surface for document mode."), + bullet( + documentsSearchHref({ query: "clozapine monitoring table", focus: true, run: true }), + "Documents search command centre used after submitting a search in Documents mode.", + ), + bullet( + documentReaderHref({ + document: "clozapine-monitoring", + query: "clozapine monitoring table", + page: 12, + chunk: "monitoring-table", + }), + "Standalone master document reader for a selected result, with bundled demo PDF content and evidence navigation.", + ), + bullet( + documentEvidenceHref({ + document: "clozapine-monitoring", + evidence: "monitoring-table", + query: "clozapine monitoring table", + page: 12, + chunk: "monitoring-table", + }), + "Reusable evidence detail page opened from the search tray or document reader evidence cards.", + ), + bullet("/documents/[id]", "Live document viewer route remains available for real document records."), + ]; +} + function section(title: string, lines: string[]) { return [`## ${title}`, "", ...lines, ""]; } @@ -304,6 +344,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { ), ...section("Mode/query routes", renderModeRoutes()), ...section("Mode page index", renderModePageIndex()), + ...section("Documents flow index", renderDocumentFlowIndex()), ...section("Registry-backed routes", [ bullet( "/services/[slug]", @@ -370,7 +411,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { : ["- No page-level redirects discovered."], ), ...section("Known caveats and stale-path flags", [ - "- No active stale internal route targets are expected in the current generated sitemap.", + "- `/mockups/*` prototype routes are development-only; production returns 404 and `robots.txt` disallows indexing.", "- `/mockups/favourites-hub` is a legacy compatibility route and should redirect to `/favourites`.", "- Registry-backed service and form pages may show sign-in, load-error, or in-app not-found states for missing per-user records.", "- Live user registries may contain additional service or form slugs beyond the seeded/demo slugs listed here.", diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 13ba50886..6913050ee 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -4,7 +4,8 @@ import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { answerQuestionWithScope } from "@/lib/rag"; import { jsonError, PublicApiError } from "@/lib/http"; -import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { publicAccessContext } from "@/lib/public-api-access"; import { classifyRagQuery } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; @@ -62,11 +63,11 @@ export async function POST(request: Request) { } const supabase = createAdminClient(); - const user = await serverAuth.requireAuthenticatedUser(request, supabase); + const access = await publicAccessContext(request, supabase); - const rateLimit = await consumeApiRateLimit({ + const rateLimit = await consumeSubjectApiRateLimit({ supabase, - ownerId: user.id, + subject: access.rateLimitSubject, bucket: "answer", allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), }); @@ -76,7 +77,7 @@ export async function POST(request: Request) { const scope = await resolveSearchScope({ supabase, - ownerId: user.id, + ownerId: access.ownerId, documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), filters: body.filters, }); @@ -101,7 +102,8 @@ export async function POST(request: Request) { documentIds: singleDocumentScope ? undefined : (scope.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined)), - ownerId: user.id, + ownerId: access.ownerId, + allowGlobalSearch: !access.ownerId, queryMode: body.queryMode, skipCache: body.skipCache, signal: request.signal, diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 507c7086c..c2dc02c19 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; -import { consumeApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit"; +import { consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit"; +import { publicAccessContext } from "@/lib/public-api-access"; import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag"; import { classifyRagQuery } from "@/lib/clinical-search"; import { annotateSearchResults, buildEvidenceRelevance } from "@/lib/evidence-relevance"; @@ -15,7 +16,7 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { logger } from "@/lib/logger"; import { parseJsonBody } from "@/lib/validation/body"; import type { RagAnswer } from "@/lib/types"; @@ -223,17 +224,17 @@ export async function POST(request: Request) { if (isDemoMode()) return streamAnswer(body, undefined, request.signal); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); + const access = await publicAccessContext(request, supabase); - const rateLimit = await consumeApiRateLimit({ + const rateLimit = await consumeSubjectApiRateLimit({ supabase, - ownerId: user.id, + subject: access.rateLimitSubject, bucket: "answer", allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), }); if (rateLimit.limited) return rateLimitStream(rateLimit); - return streamAnswer(body, user.id, request.signal); + return streamAnswer(body, access.ownerId, request.signal); } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(error); diff --git a/src/app/api/documents/bulk/route.ts b/src/app/api/documents/bulk/route.ts index 40f51eed2..c94551a28 100644 --- a/src/app/api/documents/bulk/route.ts +++ b/src/app/api/documents/bulk/route.ts @@ -140,42 +140,6 @@ export async function POST(request: Request) { const results: Array<{ documentId: string; updated: boolean; error?: string }> = []; const now = new Date().toISOString(); - for (const document of documents) { - try { - const metadata = metadataRecord(document.metadata); - setMetadataValue(metadata, "document_status", parsed.metadata.sourceStatus); - setMetadataValue(metadata, "clinical_validation_status", parsed.metadata.validationStatus); - setMetadataValue(metadata, "extraction_quality", parsed.metadata.extractionQuality); - setMetadataValue(metadata, "review_date", parsed.metadata.reviewDate); - setMetadataValue(metadata, "publication_date", parsed.metadata.publicationDate); - setMetadataValue(metadata, "jurisdiction", parsed.metadata.jurisdiction); - setMetadataValue(metadata, "publisher", parsed.metadata.publisher); - setMetadataValue(metadata, "source_type", parsed.metadata.sourceType); - setMetadataValue(metadata, "collection", parsed.metadata.collection); - setMetadataValue(metadata, "category", parsed.metadata.category); - metadata.bulk_metadata_updated_at = now; - metadata.bulk_metadata_updated_by = user.id; - - const nextTitle = editTitle(document.title, parsed.titleEdit); - const updatePayload: TablesUpdate<"documents"> = { metadata: metadata as Json }; - if (nextTitle && nextTitle !== document.title) updatePayload.title = nextTitle; - - const { error: updateError } = await supabase - .from("documents") - .update(updatePayload) - .eq("id", document.id) - .eq("owner_id", user.id); - if (updateError) throw new Error(updateError.message); - results.push({ documentId: document.id, updated: true }); - } catch (error) { - results.push({ - documentId: document.id, - updated: false, - error: error instanceof Error ? error.message : "Bulk edit failed.", - }); - } - } - const labelsToAdd = parsed.labels.add .map((label) => normalizeDocumentLabelForStorage({ ...label, source: "manual" })) .filter((label): label is NonNullable => Boolean(label)); @@ -213,6 +177,42 @@ export async function POST(request: Request) { if (removeError) throw new Error(removeError.message); } + for (const document of documents) { + try { + const metadata = metadataRecord(document.metadata); + setMetadataValue(metadata, "document_status", parsed.metadata.sourceStatus); + setMetadataValue(metadata, "clinical_validation_status", parsed.metadata.validationStatus); + setMetadataValue(metadata, "extraction_quality", parsed.metadata.extractionQuality); + setMetadataValue(metadata, "review_date", parsed.metadata.reviewDate); + setMetadataValue(metadata, "publication_date", parsed.metadata.publicationDate); + setMetadataValue(metadata, "jurisdiction", parsed.metadata.jurisdiction); + setMetadataValue(metadata, "publisher", parsed.metadata.publisher); + setMetadataValue(metadata, "source_type", parsed.metadata.sourceType); + setMetadataValue(metadata, "collection", parsed.metadata.collection); + setMetadataValue(metadata, "category", parsed.metadata.category); + metadata.bulk_metadata_updated_at = now; + metadata.bulk_metadata_updated_by = user.id; + + const nextTitle = editTitle(document.title, parsed.titleEdit); + const updatePayload: TablesUpdate<"documents"> = { metadata: metadata as Json }; + if (nextTitle && nextTitle !== document.title) updatePayload.title = nextTitle; + + const { error: updateError } = await supabase + .from("documents") + .update(updatePayload) + .eq("id", document.id) + .eq("owner_id", user.id); + if (updateError) throw new Error(updateError.message); + results.push({ documentId: document.id, updated: true }); + } catch (error) { + results.push({ + documentId: document.id, + updated: false, + error: error instanceof Error ? error.message : "Bulk edit failed.", + }); + } + } + invalidateRagCachesForOwner(user.id); return NextResponse.json({ ok: results.every((result) => result.updated), diff --git a/src/app/api/search/interaction/route.ts b/src/app/api/search/interaction/route.ts index 8b1d6db78..b05736ffb 100644 --- a/src/app/api/search/interaction/route.ts +++ b/src/app/api/search/interaction/route.ts @@ -83,7 +83,7 @@ export async function POST(request: Request) { const safeFileName = clickedDocumentId ? safeTelemetryText(body.fileName) : null; const safeTitle = clickedDocumentId ? safeTelemetryText(body.title) : null; - await supabase.from("rag_query_misses").insert({ + const { error: insertError } = await supabase.from("rag_query_misses").insert({ owner_id: user.id, query: queryTextForStorage(body.query), normalized_query: normalizedQueryTextForStorage(body.query), @@ -109,6 +109,7 @@ export async function POST(request: Request) { ...queryPrivacyMetadata(body.query), }, }); + if (insertError) throw new Error(insertError.message); return NextResponse.json({ ok: true }); } catch (error) { if (error instanceof serverAuth.AuthenticationError) { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 8c0159678..57fc06ebe 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -14,7 +14,8 @@ import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { SOURCE_ONLY_EMBEDDING_SKIP_REASON } from "@/lib/rag-provider"; import { createAdminClient } from "@/lib/supabase/admin"; import * as serverAuth from "@/lib/supabase/auth"; -import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { publicAccessContext } from "@/lib/public-api-access"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { parseJsonBody } from "@/lib/validation/body"; import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; @@ -64,9 +65,9 @@ function isSourceLibrarySearchMode(mode: SearchRequestBody["mode"]) { return mode === "documents" || mode === "differentials"; } -function scopedSearchKey(body: SearchRequestBody, ownerId: string) { +function scopedSearchKey(body: SearchRequestBody, ownerId?: string | null) { return JSON.stringify({ - ownerId, + ownerId: ownerId ?? undefined, query: body.query.toLowerCase().replace(/\s+/g, " ").trim(), topK: body.topK ?? null, documentId: body.documentId ?? null, @@ -377,7 +378,7 @@ function candidatePromotions(query: string, results: SearchResult[]) { function logWeakSearch(args: { supabase: ReturnType; - ownerId: string; + ownerId?: string | null; query: string; queryClass: string; route?: string | null; @@ -488,7 +489,7 @@ function retrievalDecisionTelemetry(telemetry: Record) { function logRetrievalDiagnostics(args: { supabase: ReturnType; - ownerId: string; + ownerId?: string | null; query: string; results: SearchResult[]; telemetry: Record; @@ -573,7 +574,7 @@ function logRetrievalDiagnostics(args: { function logSearchObservation(args: { supabase: ReturnType; - ownerId: string; + ownerId?: string | null; query: string; results: SearchResult[]; payload: Record; @@ -625,14 +626,14 @@ function logSearchObservation(args: { async function buildScopedSearchPayload( body: SearchRequestBody, supabase: ReturnType, - ownerId: string, + ownerId?: string | null, ) { const searchFocusQuery = queryForClinicalMode(body.query, body.queryMode); const effectiveQueryClass = queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(searchFocusQuery).queryClass; const scope = await resolveSearchScope({ supabase, - ownerId, + ownerId: ownerId ?? undefined, documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), filters: body.filters, }); @@ -676,7 +677,8 @@ async function buildScopedSearchPayload( ? Math.max(body.topK ?? 12, Math.min(20, body.documentLimit)) : (body.topK ?? 8), documentIds: scope.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined), - ownerId, + ownerId: ownerId ?? undefined, + allowGlobalSearch: !ownerId, queryMode: body.queryMode, }); const resultLimit = isSourceLibrarySearchMode(body.mode) @@ -692,7 +694,7 @@ async function buildScopedSearchPayload( const relatedDocuments = body.includeRelatedDocuments ? await fetchRelatedDocuments({ supabase, - ownerId, + ownerId: ownerId ?? undefined, query: searchFocusQuery, results, limit: isSourceLibrarySearchMode(body.mode) ? body.documentLimit : undefined, @@ -880,12 +882,12 @@ export async function POST(request: Request) { } supabase = createAdminClient(); - const user = await serverAuth.requireAuthenticatedUser(request, supabase); - ownerId = user.id; + const access = await publicAccessContext(request, supabase); + ownerId = access.ownerId ?? null; - const rateLimit = await consumeApiRateLimit({ + const rateLimit = await consumeSubjectApiRateLimit({ supabase, - ownerId, + subject: access.rateLimitSubject, bucket: "search", allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), }); @@ -898,7 +900,7 @@ export async function POST(request: Request) { const key = scopedSearchKey(body, ownerId); const { payload, coalesced } = await coalesceScopedSearch(key, () => - buildScopedSearchPayload(body, supabase!, ownerId!), + buildScopedSearchPayload(body, supabase!, ownerId), ); return NextResponse.json({ ...payload, diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 7aacea2c0..72ddb6023 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -21,6 +21,60 @@ const uploadMetadataSchema = z }) .strict(); +function isContentHashDuplicateError(error: unknown) { + const message = + error instanceof Error + ? error.message + : error && typeof error === "object" && "message" in error && typeof error.message === "string" + ? error.message + : String(error); + return ( + /duplicate key value violates unique constraint/i.test(message) && + /content_hash|documents_owner_content_hash/i.test(message) + ); +} + +async function duplicateUploadResponse(args: { + supabase: ReturnType; + ownerId: string; + contentHash: string; + storagePath: string | null; +}) { + if (args.storagePath) { + const { error: cleanupStorageError } = await args.supabase.storage + .from(env.SUPABASE_DOCUMENT_BUCKET) + .remove([args.storagePath]); + if (cleanupStorageError) { + logger.warn("Duplicate upload storage cleanup failed", { + storagePath: args.storagePath, + message: cleanupStorageError.message, + }); + } + } + + const { data: duplicate, error: duplicateError } = await args.supabase + .from("documents") + .select("id,title,file_name,status,page_count,chunk_count,image_count,created_at") + .eq("owner_id", args.ownerId) + .eq("content_hash", args.contentHash) + .maybeSingle(); + + if (duplicateError) throw new Error(duplicateError.message); + if (!duplicate?.id) { + throw new PublicApiError( + "Upload conflicted with an existing document but the duplicate could not be resolved.", + 409, + ); + } + + return NextResponse.json({ + document: duplicate, + duplicate: true, + duplicateReason: "exact_content_hash", + message: `Exact copy already exists as "${duplicate.title}"; no duplicate job was queued.`, + }); +} + export async function POST(request: Request) { let supabase: ReturnType | null = null; let uploadedPath: string | null = null; @@ -142,7 +196,19 @@ export async function POST(request: Request) { .select() .single(); - if (documentError) throw new Error(documentError.message); + if (documentError) { + if (isContentHashDuplicateError(documentError)) { + insertedDocumentId = null; + insertedDocumentOwnerId = null; + return duplicateUploadResponse({ + supabase, + ownerId: user.id, + contentHash, + storagePath: uploadedPath, + }); + } + throw new Error(documentError.message); + } insertedDocumentId = documentId; insertedDocumentOwnerId = user.id; diff --git a/src/app/applications/loading.tsx b/src/app/applications/loading.tsx new file mode 100644 index 000000000..59334e70b --- /dev/null +++ b/src/app/applications/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/differentials/loading.tsx b/src/app/differentials/loading.tsx new file mode 100644 index 000000000..59334e70b --- /dev/null +++ b/src/app/differentials/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/documents/[id]/loading.tsx b/src/app/documents/[id]/loading.tsx index 81ca71167..44dbfbec9 100644 --- a/src/app/documents/[id]/loading.tsx +++ b/src/app/documents/[id]/loading.tsx @@ -1,7 +1,9 @@ +import { DocumentViewerPageSkeleton } from "@/components/mode-home-page-skeleton"; + export default function Loading() { return ( -
-
+
+
); } diff --git a/src/app/documents/documents-layout-client.tsx b/src/app/documents/documents-layout-client.tsx new file mode 100644 index 000000000..72fbf7c90 --- /dev/null +++ b/src/app/documents/documents-layout-client.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; + +export function DocumentsLayoutClient({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const isDocumentSearchRoute = pathname === "/documents/search"; + const documentViewerOwnsMobileChrome = pathname.startsWith("/documents/") && pathname !== "/documents/search"; + const documentFlowOwnsMobileChrome = pathname.startsWith("/documents/source") || documentViewerOwnsMobileChrome; + + return ( + + {children} + + ); +} diff --git a/src/app/documents/layout.tsx b/src/app/documents/layout.tsx index faaa8decb..2d7b031b9 100644 --- a/src/app/documents/layout.tsx +++ b/src/app/documents/layout.tsx @@ -1,11 +1,7 @@ import type { ReactNode } from "react"; -import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; +import { DocumentsLayoutClient } from "@/app/documents/documents-layout-client"; export default function DocumentsLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ); + return {children}; } diff --git a/src/app/documents/search/loading.tsx b/src/app/documents/search/loading.tsx new file mode 100644 index 000000000..7a40dd076 --- /dev/null +++ b/src/app/documents/search/loading.tsx @@ -0,0 +1,9 @@ +import { DocumentSearchPageSkeleton } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/documents/search/page.tsx b/src/app/documents/search/page.tsx new file mode 100644 index 000000000..10162ee0d --- /dev/null +++ b/src/app/documents/search/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { MasterDocumentSearch } from "@/components/master-document-flow-mockups"; + +export const metadata: Metadata = { + title: "Document Search - Clinical KB", + description: "Search indexed clinical documents and review matching evidence.", +}; + +export default function DocumentsSearchRoute() { + return ; +} diff --git a/src/app/documents/source/evidence/page.tsx b/src/app/documents/source/evidence/page.tsx new file mode 100644 index 000000000..957970ca7 --- /dev/null +++ b/src/app/documents/source/evidence/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { MasterEvidenceDetail } from "@/components/master-document-flow-mockups"; + +export const metadata: Metadata = { + title: "Evidence Detail - Clinical KB", + description: "Evidence detail for tables, quotes, images, and source page context.", +}; + +export default function DocumentsEvidenceRoute() { + return ( + + + + ); +} diff --git a/src/app/mockups/document-search/source/page.tsx b/src/app/documents/source/page.tsx similarity index 56% rename from src/app/mockups/document-search/source/page.tsx rename to src/app/documents/source/page.tsx index 5143ab6b1..3705ef0a0 100644 --- a/src/app/mockups/document-search/source/page.tsx +++ b/src/app/documents/source/page.tsx @@ -4,11 +4,11 @@ import { Suspense } from "react"; import { MasterDocumentReader } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { - title: "Document Reader Mockup - Clinical KB", - description: "Functional document reader mockup with bundled PDF content, highlights, and evidence inspector.", + title: "Document Reader - Clinical KB", + description: "Document reader with highlights, bundled demo PDF content, and evidence navigation.", }; -export default function HighlightedDocumentSearchSourceRoute() { +export default function DocumentsSourceRoute() { return ( diff --git a/src/app/favourites/legacy/page.tsx b/src/app/favourites/legacy/page.tsx deleted file mode 100644 index e0095f8b6..000000000 --- a/src/app/favourites/legacy/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { FavouritesHomePage } from "@/components/clinical-dashboard/favourites-home-page"; - -type LegacyFavouritesPageProps = { - searchParams?: Promise<{ - q?: string | string[]; - }>; -}; - -function firstSearchParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0] : value; -} - -export default async function LegacyFavouritesPage({ searchParams }: LegacyFavouritesPageProps) { - const params = searchParams ? await searchParams : {}; - const query = firstSearchParam(params.q)?.trim() ?? ""; - - return ; -} diff --git a/src/app/favourites/loading.tsx b/src/app/favourites/loading.tsx new file mode 100644 index 000000000..59334e70b --- /dev/null +++ b/src/app/favourites/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/favourites/page.tsx b/src/app/favourites/page.tsx index d8317ff84..38a1c38de 100644 --- a/src/app/favourites/page.tsx +++ b/src/app/favourites/page.tsx @@ -1,5 +1,18 @@ import { FavouritesCommandLibraryPage } from "@/components/clinical-dashboard/favourites-command-library-page"; -export default function FavouritesPage() { - return ; +type FavouritesPageProps = { + searchParams?: Promise<{ + q?: string | string[]; + }>; +}; + +function firstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function FavouritesPage({ searchParams }: FavouritesPageProps) { + const params = searchParams ? await searchParams : {}; + const query = firstSearchParam(params.q)?.trim() ?? ""; + + return ; } diff --git a/src/app/forms/loading.tsx b/src/app/forms/loading.tsx new file mode 100644 index 000000000..59334e70b --- /dev/null +++ b/src/app/forms/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index b6fa79258..b1873a02d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -148,6 +148,26 @@ --clinical-chat-table-header: #f7f8fa; --clinical-chat-ready: var(--success); + /* Favourite type chips — categorical, not semantic status. */ + --type-document: #1d4ed8; + --type-document-soft: #eff6ff; + --type-document-border: #bfdbfe; + --type-table: #047857; + --type-table-soft: #ecfdf5; + --type-table-border: #a7f3d0; + --type-search: #334155; + --type-search-soft: #f8fafc; + --type-search-border: #e2e8f0; + --type-source: #6d28d9; + --type-source-soft: #f5f3ff; + --type-source-border: #ddd6fe; + --type-service: #0e7490; + --type-service-soft: #ecfeff; + --type-service-border: #a5f3fc; + --type-form: #b45309; + --type-form-soft: #fffbeb; + --type-form-border: #fde68a; + /* Semantic triads (text / background / border), on-white for light mode. */ --info-text: #2563eb; --info-bg: #eff4ff; @@ -293,6 +313,25 @@ --clinical-chat-table-header: #141619; --clinical-chat-ready: var(--success); + --type-document: #93c5fd; + --type-document-soft: #172554; + --type-document-border: #1e3a8a; + --type-table: #6ee7b7; + --type-table-soft: #064e3b; + --type-table-border: #047857; + --type-search: #cbd5e1; + --type-search-soft: #1e293b; + --type-search-border: #475569; + --type-source: #c4b5fd; + --type-source-soft: #2e1065; + --type-source-border: #5b21b6; + --type-service: #67e8f9; + --type-service-soft: #164e63; + --type-service-border: #0e7490; + --type-form: #fcd34d; + --type-form-soft: #451a03; + --type-form-border: #92400e; + --info-text: #89c0f0; --info-bg: #122c4c; --info-border: #2c537f; @@ -588,6 +627,130 @@ summary::-webkit-details-marker { -webkit-mask-image: linear-gradient(180deg, transparent 0%, black 32%, black 100%); } +/* Phone dock: full-bleed footer with progressive blur scrim behind pill + chips. */ +.answer-footer-search-dock { + --footer-scrim-height: max(10rem, calc(var(--safe-area-bottom) + 8.5rem)); +} + +.answer-footer-search-dock[data-footer-variant="compact"] { + --footer-scrim-height: max(7rem, calc(var(--safe-area-bottom) + 5.5rem)); +} + +/* Bottom-docked composer: hint row + upward dropdown grow above the pill. */ +.answer-footer-search-edge { + overflow: visible; +} + +.answer-footer-search-edge .universal-command-surface, +.answer-footer-search-edge .universal-command-surface > .relative { + overflow: visible; +} + +.answer-footer-search-edge[data-command-open="true"] .answer-footer-search-backdrop { + height: max(18rem, calc(var(--safe-area-bottom) + 16rem)); +} + +.answer-footer-search-dock[data-command-open="true"] { + --footer-scrim-height: max(22rem, calc(var(--safe-area-bottom) + 20rem)); +} + +.answer-footer-search-dock[data-command-open="true"][data-footer-variant="compact"] { + --footer-scrim-height: max(18rem, calc(var(--safe-area-bottom) + 16rem)); +} + +.answer-footer-search-edge .universal-command-dropdown { + border-bottom-left-radius: 1rem; + border-bottom-right-radius: 1rem; +} + +.answer-footer-search-dock .answer-footer-search-backdrop { + position: fixed; + inset-inline: 0; + bottom: 0; + top: auto; + z-index: 0; + height: var(--footer-scrim-height); + overflow: hidden; + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--background) 8%, transparent) 38%, + color-mix(in srgb, var(--background) 18%, transparent) 68%, + color-mix(in srgb, var(--background) 32%, transparent) 100% + ); + backdrop-filter: blur(2px) saturate(130%); + -webkit-backdrop-filter: blur(2px) saturate(130%); + mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 60%, black 100%); + -webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 60%, black 100%); +} + +.answer-footer-search-dock .answer-footer-search-backdrop::before, +.answer-footer-search-dock .answer-footer-search-backdrop::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; +} + +.answer-footer-search-dock .answer-footer-search-backdrop::before { + backdrop-filter: blur(8px) saturate(130%); + -webkit-backdrop-filter: blur(8px) saturate(130%); + mask-image: linear-gradient(180deg, transparent 0%, rgb(0 0 0 / 35%) 20%, black 45%, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient( + 180deg, + transparent 0%, + rgb(0 0 0 / 35%) 20%, + black 45%, + black 80%, + transparent 100% + ); +} + +.answer-footer-search-dock .answer-footer-search-backdrop::after { + backdrop-filter: blur(22px) saturate(140%); + -webkit-backdrop-filter: blur(22px) saturate(140%); + mask-image: linear-gradient(180deg, transparent 0%, transparent 55%, black 72%, black 100%); + -webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 55%, black 72%, black 100%); +} + +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .answer-footer-search-dock .answer-footer-search-backdrop, + .answer-footer-search-dock .answer-footer-search-backdrop::before, + .answer-footer-search-dock .answer-footer-search-backdrop::after { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .answer-footer-search-dock .answer-footer-search-backdrop { + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--background) 45%, transparent) 40%, + color-mix(in srgb, var(--background) 78%, transparent) 72%, + color-mix(in srgb, var(--background) 92%, transparent) 100% + ); + } +} + +@media (prefers-reduced-transparency: reduce) { + .answer-footer-search-dock .answer-footer-search-backdrop, + .answer-footer-search-dock .answer-footer-search-backdrop::before, + .answer-footer-search-dock .answer-footer-search-backdrop::after { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .answer-footer-search-dock .answer-footer-search-backdrop { + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--background) 50%, transparent) 42%, + color-mix(in srgb, var(--background) 82%, transparent) 74%, + color-mix(in srgb, var(--background) 94%, transparent) 100% + ); + } +} + .answer-footer-search-pill:hover { border-color: var(--border-strong); box-shadow: @@ -613,13 +776,11 @@ summary::-webkit-details-marker { } .answer-footer-search-pill-open[data-menu-placement="up"] { - border-top-left-radius: 1rem; - border-top-right-radius: 1rem; + border-radius: 999px; } .answer-footer-search-pill-open[data-menu-placement="down"] { - border-bottom-left-radius: 1rem; - border-bottom-right-radius: 1rem; + border-radius: 999px; } .mode-action-surface { @@ -632,6 +793,44 @@ summary::-webkit-details-marker { transform-origin: center top; } +.mode-action-surface[data-placement="up"]::after { + content: ""; + position: absolute; + bottom: -0.42rem; + left: 50%; + z-index: 2; + width: 0.9rem; + height: 0.9rem; + transform: translateX(-50%) rotate(45deg); + border: 1px solid color-mix(in srgb, var(--border-lux) 86%, transparent); + border-left: 0; + border-top: 0; + border-bottom-right-radius: 0.18rem; + background: var(--surface-raised); + box-shadow: 5px 5px 14px rgb(15 37 48 / 6%); +} + +.mode-action-surface[data-placement="down"]::before { + content: ""; + position: absolute; + top: -0.42rem; + left: 50%; + z-index: 2; + width: 0.9rem; + height: 0.9rem; + transform: translateX(-50%) rotate(45deg); + border: 1px solid color-mix(in srgb, var(--border-lux) 86%, transparent); + border-right: 0; + border-bottom: 0; + border-top-left-radius: 0.18rem; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--clinical-accent) 78%, #ffffff 22%) 0%, + color-mix(in srgb, var(--primary-700) 82%, #ffffff 18%) 100% + ); + box-shadow: -5px -5px 14px rgb(15 37 48 / 6%); +} + .mode-action-panel { max-height: var(--mode-action-max-height); backdrop-filter: blur(18px) saturate(140%); @@ -839,36 +1038,34 @@ summary::-webkit-details-marker { @media (max-width: 430px) { .mode-action-header { - min-height: 5.7rem; - grid-template-areas: - "selector close" - "summary summary"; - grid-template-columns: minmax(0, 1fr) 2.85rem; - gap: 0.5rem 0.65rem; - padding: 0.62rem; + min-height: 3.7rem; + grid-template-areas: "selector close"; + grid-template-columns: minmax(0, 1fr) 2.35rem; + gap: 0.45rem; + padding: 0.48rem; } .mode-action-mode-button { - min-height: 2.85rem; - gap: 0.55rem; - padding-inline: 0.58rem; - font-size: 0.95rem; + min-height: 2.25rem; + gap: 0.42rem; + padding-inline: 0.5rem; + font-size: 0.9rem; } .mode-action-mode-icon { - height: 1.95rem; - width: 1.95rem; + height: 1.45rem; + width: 1.45rem; + border-radius: 0.42rem; } - .mode-action-header-summary { - gap: 0; - padding-inline: 0.18rem; - font-size: 0.83rem; - white-space: normal; + .mode-action-mode-icon svg, + .mode-action-mode-option-icon svg { + height: 0.9rem; + width: 0.9rem; } - .mode-action-header-summary .truncate { - white-space: normal; + .mode-action-header-summary { + display: none; } .mode-action-header-divider { @@ -876,13 +1073,43 @@ summary::-webkit-details-marker { } .mode-action-close { - height: 2.55rem; - width: 2.55rem; + height: 2.05rem; + width: 2.05rem; + } + + .mode-action-close svg { + height: 0.95rem; + width: 0.95rem; } .mode-action-mode-menu { width: min(20rem, calc(100vw - 1.5rem)); } + + .mode-action-mode-option { + min-height: 2.75rem; + grid-template-columns: 1.75rem minmax(0, 1fr) auto; + gap: 0.5rem; + padding: 0.38rem 0.45rem; + } + + .mode-action-mode-option-icon { + height: 1.7rem; + width: 1.7rem; + border-radius: 0.42rem; + } + + .answer-footer-search-action, + .answer-footer-search-send { + height: 2.05rem; + width: 2.05rem; + } + + .answer-footer-search-action svg, + .answer-footer-search-send svg { + height: 1rem; + width: 1rem; + } } @media (prefers-reduced-motion: no-preference) { @@ -942,6 +1169,20 @@ summary::-webkit-details-marker { box-shadow: 0 5px 14px color-mix(in srgb, var(--clinical-accent) 38%, transparent); } +@media (max-width: 430px) { + .answer-footer-search-action, + .answer-footer-search-send { + height: 2.05rem !important; + width: 2.05rem !important; + } + + .answer-footer-search-action svg, + .answer-footer-search-send svg { + height: 1rem; + width: 1rem; + } +} + @media (prefers-reduced-motion: no-preference) { .answer-footer-search-send:active { transform: scale(0.96); @@ -1091,6 +1332,26 @@ summary::-webkit-details-marker { transform: translateX(-50%); } + /* Compact search/result views: no chip row below the pill, so the pill + itself hugs the bottom edge and the scrim shrinks to match. */ + .document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact { + bottom: max(0.4rem, calc(var(--safe-area-bottom) + 0.3rem)); + } + + .document-mobile-search-compact .answer-footer-search-backdrop { + height: max(5rem, calc(var(--safe-area-bottom) + 4.4rem)); + } + + /* Differentials search results: compare action sits above the search pill in the dock. */ + .answer-footer-search-dock[data-footer-addon="differentials-compare"] .answer-footer-search-backdrop { + height: max(8.75rem, calc(var(--safe-area-bottom) + 7.5rem)); + } + + .answer-footer-search-dock.document-mobile-search-compact[data-footer-addon="differentials-compare"] + .answer-footer-search-backdrop { + height: max(8.5rem, calc(var(--safe-area-bottom) + 7.25rem)); + } + .document-mobile-search-pill { min-height: 3.6rem; border-color: var(--border-strong); @@ -1100,6 +1361,78 @@ summary::-webkit-details-marker { 0 1px 2px rgb(16 24 40 / 5%), 0 8px 22px rgb(16 24 40 / 8%); } + + /* Edge-to-edge phone dock: full-width footer, progressive scrim, inset pill. */ + .answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge, + .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge { + left: 0; + right: 0; + bottom: 0; + width: 100%; + max-width: none; + transform: none; + padding-inline: max(0.75rem, var(--safe-area-left)) max(0.75rem, var(--safe-area-right)); + padding-top: 0.5rem; + padding-bottom: max(0.5rem, var(--safe-area-bottom)); + } + + .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact { + padding-bottom: max(0.45rem, var(--safe-area-bottom)); + } + + .answer-footer-search-dock .answer-footer-search-pill { + border-color: var(--border-strong); + background: var(--surface); + box-shadow: + 0 -1px 0 color-mix(in srgb, var(--border) 60%, transparent), + 0 1px 3px rgb(16 24 40 / 5%); + } + + .answer-footer-search-dock .answer-footer-search-chip { + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .answer-footer-search-dock .answer-footer-search-pill:hover { + box-shadow: + 0 -1px 0 color-mix(in srgb, var(--border-strong) 70%, transparent), + 0 2px 6px rgb(16 24 40 / 7%); + } + + .answer-footer-search-dock .answer-footer-search-pill:focus-within { + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 16%, transparent), + 0 -1px 0 color-mix(in srgb, var(--border) 60%, transparent), + 0 2px 6px rgb(16 24 40 / 6%); + } + + .answer-footer-search-dock .answer-footer-search-pill-open { + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 10%, transparent), + 0 -1px 0 color-mix(in srgb, var(--border) 60%, transparent), + 0 2px 8px rgb(16 24 40 / 8%); + } +} + +/* Tablet rail range: the collapsed icon rail occupies the left edge from md + until the full sidebar takes over at lg, so fixed composers must centre on + the remaining content area instead of the viewport. Shells set + --clinical-sidebar-width-md (5.25rem, or 0px when the sidebar is hidden). */ +@media (min-width: 768px) and (max-width: 1023.98px) { + .dashboard-composer-edge { + left: calc(var(--clinical-sidebar-width-md, 0px) + max(0.75rem, var(--safe-area-left))); + right: max(0.75rem, var(--safe-area-right)); + } + + .dashboard-composer-edge.answer-footer-search-edge { + left: calc(var(--clinical-sidebar-width-md, 0px) + (100vw - var(--clinical-sidebar-width-md, 0px)) / 2); + right: auto; + width: min( + calc(100vw - var(--clinical-sidebar-width-md, 0px) - 48px - var(--safe-area-left) - var(--safe-area-right)), + 680px + ); + } } @media (min-width: 1024px) { @@ -1141,6 +1474,50 @@ summary::-webkit-details-marker { font-variant-numeric: tabular-nums; } +@utility search-results-main { + container-type: inline-size; + container-name: search-results-main; + min-width: 0; +} + +.search-results-cards-compact { + display: none; +} + +.search-results-table-desktop { + display: block; +} + +@media (max-width: 1023px) { + .search-results-table-desktop { + display: none !important; + } + + .search-results-cards-compact { + display: block !important; + } +} + +@container search-results-main (max-width: 56rem) { + .search-results-table-desktop { + display: none; + } + + .search-results-cards-compact { + display: block; + } +} + +@container search-results-main (min-width: 56.01rem) { + .search-results-table-desktop { + display: block; + } + + .search-results-cards-compact { + display: none; + } +} + /* Motion keyframes (suppressed under prefers-reduced-motion below) */ @keyframes fade-up { from { diff --git a/src/app/home-page-client.tsx b/src/app/home-page-client.tsx new file mode 100644 index 000000000..97278e45d --- /dev/null +++ b/src/app/home-page-client.tsx @@ -0,0 +1,10 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; +import type { AppModeId } from "@/lib/app-modes"; + +export function HomePageClient({ initialMode, children }: { initialMode: AppModeId; children?: ReactNode }) { + return {children ?? null}; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 27222d476..d9b63b84d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -46,7 +46,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} suppressHydrationWarning > - + {/* Applies the resolved theme before first paint on every route (standalone pages don't mount useTheme, and hydration-time toggling flashes light). Mirrors resolveThemePreference in src/lib/theme.ts: stored choice wins, @@ -58,6 +58,7 @@ export default function RootLayout({ /> Skip to main content diff --git a/src/app/loading.tsx b/src/app/loading.tsx index 81ca71167..59334e70b 100644 --- a/src/app/loading.tsx +++ b/src/app/loading.tsx @@ -1,7 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + export default function Loading() { - return ( -
-
-
- ); + return ; } diff --git a/src/app/mockups/answer-evidence-popups/page.tsx b/src/app/mockups/answer-evidence-popups/page.tsx index 1492c615a..791dff886 100644 --- a/src/app/mockups/answer-evidence-popups/page.tsx +++ b/src/app/mockups/answer-evidence-popups/page.tsx @@ -573,36 +573,37 @@ function MobileEvidencePanel({ selected }: { selected: string }) { ); } -function DesktopEvidenceDrawer() { +function DesktopEvidenceModal() { return ( -
-
-
-

Evidence review

-

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

-
- - - Source-backed - -
-
-
- - -
-
-
-

Pinned source

-

Clozapine physical health protocol

-
- Current - Locally reviewed +
+ @@ -709,10 +710,10 @@ export default function AnswerEvidencePopupsMockupPage() {
- +
; -} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 2ebc5637c..7ae81f3bf 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -11,9 +11,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const isFavouritesPageMockup = pathname.startsWith("/mockups/favourites-"); const isDocumentSearchMockup = pathname.startsWith("/mockups/document-search"); const isSourceOverlayRedesignMockup = pathname === "/mockups/document-search/source-overlays"; - const isStandaloneDocumentFlow = - pathname === "/mockups/document-search" || pathname.startsWith("/mockups/document-search/source"); - const documentFlowOwnsMobileChrome = pathname.startsWith("/mockups/document-search/source"); + const isStandaloneDocumentFlow = pathname === "/mockups/document-search"; + const isUniversalSearchRedesignMockup = pathname === "/mockups/universal-search-redesign"; return ( {children} diff --git a/src/app/mockups/universal-search-command/page.tsx b/src/app/mockups/universal-search-command/page.tsx new file mode 100644 index 000000000..6eb31a2d2 --- /dev/null +++ b/src/app/mockups/universal-search-command/page.tsx @@ -0,0 +1,5 @@ +import { UniversalSearchCommandMockupsPage } from "@/components/universal-search-command-mockups"; + +export default function UniversalSearchCommandMockupRoute() { + return ; +} diff --git a/src/app/mockups/universal-search-redesign/page.tsx b/src/app/mockups/universal-search-redesign/page.tsx new file mode 100644 index 000000000..4b7889f60 --- /dev/null +++ b/src/app/mockups/universal-search-redesign/page.tsx @@ -0,0 +1,5 @@ +import { UniversalSearchRedesignMockupsPage } from "@/components/universal-search-redesign-mockups"; + +export default function UniversalSearchRedesignMockupRoute() { + return ; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index e6d9b98a0..adcf4a466 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,4 @@ -import { ClinicalDashboardLazy as ClinicalDashboard } from "@/components/clinical-dashboard-lazy"; +import { HomePageClient } from "@/app/home-page-client"; import { isAppModeId, isAppModeVisible, type AppModeId } from "@/lib/app-modes"; type HomeProps = { @@ -17,18 +17,8 @@ function firstSearchParam(value: string | string[] | undefined) { export default async function Home({ searchParams }: HomeProps) { const params = searchParams ? await searchParams : {}; const requestedMode = firstSearchParam(params.mode); - const requestedQuery = firstSearchParam(params.q)?.trim() ?? ""; - const requestedFocus = firstSearchParam(params.focus); - const requestedRun = firstSearchParam(params.run); const initialSearchMode: AppModeId = isAppModeId(requestedMode) && isAppModeVisible(requestedMode) ? requestedMode : "answer"; - return ( - - ); + return ; } diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 000000000..eab62fe24 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,10 @@ +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + disallow: ["/mockups/"], + }, + }; +} diff --git a/src/app/services-navigator-preview/page.tsx b/src/app/services-navigator-preview/page.tsx deleted file mode 100644 index 9c80c1071..000000000 --- a/src/app/services-navigator-preview/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ServicesNavigatorPreview } from "@/components/services/services-navigator-preview"; - -export default function ServicesNavigatorPreviewRoute() { - return ; -} diff --git a/src/app/services/loading.tsx b/src/app/services/loading.tsx new file mode 100644 index 000000000..59334e70b --- /dev/null +++ b/src/app/services/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 49ba42517..67c84fd25 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -47,16 +47,7 @@ import { Wrench, X, } from "lucide-react"; -import { - type CSSProperties, - type FormEvent, - type RefObject, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AccessibleTable } from "@/components/AccessibleTable"; import { DocumentOrganizationBadges, @@ -88,7 +79,6 @@ import { SourceProvenance, SourceStatusBadge, sourceCard, - subtleStatusPill, tableCard, tableCardHeader, tableMicroActionRow, @@ -103,7 +93,8 @@ import { useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; -import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; +import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; +import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; @@ -121,6 +112,7 @@ import { IngestionQualityConsole, LibraryHealthStrip, fallbackSetupChecks, + hasReadyRequiredPublicSearchConfig, hasReadyPublicSearchSetup, type SetupCheck, type IngestionQualityReviewItem, @@ -142,16 +134,10 @@ import { SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; +import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; import { AnswerFeedbackPanel, AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, type EvidenceTabName, simpleClinicalTableProps, evidenceMapRowsFromRenderModel, @@ -160,10 +146,9 @@ import { formatQuoteCardsForClipboard, primaryVisualTable, QuoteCards, - SafetyFindingsPanel, } from "@/components/clinical-dashboard/evidence-panels"; -import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; @@ -214,9 +199,17 @@ import { type AppModeId, type AppModeSearchKind, } from "@/lib/app-modes"; +import { documentsSearchHref } from "@/lib/document-flow-routes"; import { rankFormRecords } from "@/lib/forms"; import { rankServiceRecords } from "@/lib/services"; import { useRegistryRecords } from "@/lib/use-registry-records"; +import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up"; +import { + clearPersistedAnswerThread, + loadPersistedAnswerThread, + maxStoredAnswerTurns, + savePersistedAnswerThread, +} from "@/lib/answer-thread-storage"; import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; import { @@ -257,8 +250,13 @@ import type { DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; -import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; +import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { + createQuoteFollowUp, + type AnswerEvidenceMapRow, + type AnswerViewMode, + shouldPollForUpdates, +} from "@/lib/ward-output"; export const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; export const mobileSectionFabMediaQuery = @@ -316,15 +314,8 @@ type BatchesPayload = { hasActiveBatches?: boolean; pollAfterMs?: number | null; }; -export type AnswerFeedbackType = - | "verified" - | "needs_correction" - | "source_insufficient" - | "wrong_source" - | "missing_source" - | "unsupported_answer" - | "numeric_error" - | "outdated_guidance"; +import type { AnswerFeedbackType } from "@/lib/answer-feedback"; +export type { AnswerFeedbackType } from "@/lib/answer-feedback"; type IngestionQualityPayload = { items?: IngestionQualityReviewItem[]; demoMode?: boolean; @@ -1058,414 +1049,86 @@ function MobileEvidenceTabPanel({ return ; } -function RelatedDocumentsPanel({ - documents, - onScopeDocument, - onTagSearch, -}: { - documents: RelatedDocument[]; - onScopeDocument: (documentId: string) => void; - onTagSearch: (tag: SmartDocumentTag) => void; -}) { - if (documents.length === 0) return null; - - return ( - -
- {documents.map((document) => ( -
-
-
- - {documentDisplayTitle(document)} - - -

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

-
- -
- {document.summary && ( -

- -

- )} - -
- ))} -
-
- ); -} - -function StagedAnswerResultSurface({ - answer, - query, - safeAnswerText, - bestSource, - sourceGovernanceWarnings, - sourceSummary, - renderModel, - weakEvidence, - answerViewMode, - answerEvidenceMapRows, - onScopeDocument, - answerGrounded, - sources, - demoMode, - safeAnswerSections, - safetyFindings, - copiedAnswer, - pendingFeedback, - onCopyAnswer, - onSubmitFeedback, -}: { - answer: RagAnswer; +/** + * A completed Q&A exchange kept on screen after a newer answer arrives, so + * Answer mode reads as a conversation thread instead of replacing each result. + */ +type AnswerTurn = { + id: string; query: string; - safeAnswerText: string; - bestSource: BestSourceRecommendation | null; - sourceGovernanceWarnings: SourceGovernanceWarning[]; - sourceSummary?: EvidenceSummary; - renderModel: AnswerRenderModel; - weakEvidence: boolean; - answerViewMode: AnswerViewMode; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - onScopeDocument: (documentId: string) => void; - answerGrounded: boolean; + answer: RagAnswer; sources: SearchResult[]; - demoMode: boolean; - safeAnswerSections: Array; - safetyFindings: ReturnType; - copiedAnswer: boolean; - pendingFeedback: AnswerFeedbackType | null; - onCopyAnswer: () => void; - onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; +}; + +const maxVisiblePriorTurns = 10; + +/** + * Read-only surface for a previous turn in the answer thread. Renders the + * question bubble and the natural-language answer with its source capsule; + * evidence drawers, clinical notes, and feedback stay on the latest turn only. + */ +function PriorAnswerTurnSurface({ + turn, + copied, + collapsed, + onToggleCollapsed, + onCopy, +}: { + turn: AnswerTurn; + copied: boolean; + collapsed: boolean; + onToggleCollapsed: () => void; + onCopy: (text: string) => void; }) { - const noteCount = clinicalNotesCount(answer); - const showClinicalNotes = safetyFindings.length > 0 || noteCount > 0; - const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( - answer, - answerViewMode, - noteCount || safetyFindings.length, + const renderModel = useMemo( + () => buildAnswerRenderModel(turn.answer, { sources: turn.sources }), + [turn.answer, turn.sources], ); + const safeText = useMemo(() => sanitizeAnswerDisplayText(turn.answer.answer), [turn.answer.answer]); + const weakEvidence = renderModel.trust === "unsupported" || renderModel.trust === "low"; + const grounded = + turn.answer.grounded === true && turn.answer.confidence !== "unsupported" && renderModel.trust !== "unsupported"; const sourceCount = renderModel.primarySources.length || - sourceSummary?.total_sources || - sources.length || - answer.sources?.length || - answer.citations.length; - const centralTable = answerHasCentralTable(answer) ? primaryVisualTable(answer) : null; - const showEvidenceDrawer = renderModel.allowedBlocks.some((block) => - ["sourceStatus", "reviewSources", "evidenceMap", "quoteCards", "visualEvidence", "warnings"].includes(block), - ); - const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); - const [evidenceOpen, setEvidenceOpen] = useState(false); - const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); - const [activeReviewPanel, setActiveReviewPanel] = useState<"clinical" | "evidence" | null>(null); - const [copiedQuotes, setCopiedQuotes] = useState(false); - const clinicalNotesTriggerRef = useRef(null); - const evidenceTriggerRef = useRef(null); - const useReviewSheet = useMobilePreviewSheet(); - const copyQuotesTimerRef = useRef(null); - useEffect(() => { - return () => { - if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); - }; - }, []); - function openClinicalNotes() { - setEvidenceOpen(false); - setEvidenceInitialTab(null); - if (useReviewSheet) { - setActiveReviewPanel(null); - setClinicalNotesOpen(true); - return; - } - setClinicalNotesOpen(false); - setActiveReviewPanel("clinical"); - } - function restoreFocusToTrigger(ref: RefObject) { - window.requestAnimationFrame(() => { - if (ref.current?.isConnected) ref.current.focus({ preventScroll: true }); - }); - } - function closeClinicalNotesReview() { - setClinicalNotesOpen(false); - restoreFocusToTrigger(clinicalNotesTriggerRef); - } - function openEvidence(initialTab: EvidenceTabName | null = null) { - setClinicalNotesOpen(false); - setEvidenceInitialTab(initialTab); - if (useReviewSheet) { - setActiveReviewPanel(null); - setEvidenceOpen(true); - return; - } - setEvidenceOpen(false); - setActiveReviewPanel("evidence"); - } - function closeEvidenceReview() { - setEvidenceOpen(false); - setEvidenceInitialTab(null); - restoreFocusToTrigger(evidenceTriggerRef); - } - function closeDesktopReviewPanel() { - const triggerRef = activeReviewPanel === "clinical" ? clinicalNotesTriggerRef : evidenceTriggerRef; - setActiveReviewPanel(null); - restoreFocusToTrigger(triggerRef); - } - function openTableEvidence() { - setClinicalNotesOpen(false); - openEvidence("Tables"); - } - const copyQuotes = useCallback(async () => { - const quoteText = formatQuoteCardsForClipboard(renderModel.quoteCards); - if (!quoteText) return; - try { - await navigator.clipboard.writeText(quoteText); - setCopiedQuotes(true); - if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); - copyQuotesTimerRef.current = window.setTimeout(() => setCopiedQuotes(false), 1600); - } catch { - setCopiedQuotes(false); - } - }, [renderModel.quoteCards]); - const priority = answerSupportPriority(answer, safeAnswerSections, centralTable, safetyFindings, { - grounded: answerGrounded, - weakEvidence, - }); - const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); - const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; - const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); - const showLayoutAside = Boolean(activeReviewPanel || centralTable); + turn.sources.length || + turn.answer.sources?.length || + turn.answer.citations.length; + const previewText = safeText || turn.answer.answer; return ( -
+
- - -
+ -
-
- {activeReviewPanel === "clinical" ? ( - - ) : ( - - )} -
- - ) : centralTable ? ( -
- -
- ) : null} -
- - {showClinicalNotes ? ( - - - - } - titleAccessory={ - - {clinicalNoteDisplayCount} - - } - headerActions={ - bestSource ? ( - - - - ) : null - } - headerClassName="gap-2 p-2.5 sm:p-3" - titleClassName="text-[15px] leading-5" - closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" - contentStyle={{ height: "80dvh" }} - bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={clinicalNotesTriggerRef} - portal - > - - - ) : null} - - {showEvidenceDrawer ? ( - {evidenceTrustLabel} - } - closeLabel="Close evidence" - headerLeading={ - - - - } - contentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" - contentStyle={{ height: "80dvh" }} - bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={evidenceTriggerRef} - portal - > - - - ) : null} +
- -
); } @@ -1613,7 +1276,7 @@ function DocumentLabelReviewPanel({
{documentDisplayTitle(item.document)} @@ -2260,7 +1923,7 @@ function DocumentDrawer({ id="needs-review-filter" checked={showNeedsReviewOnly} onChange={(e) => setShowNeedsReviewOnly(e.target.checked)} - className="rounded border-[color:var(--border)] text-[color:var(--primary)] focus:ring-[color:var(--primary)] h-4 w-4" + className="rounded border-[color:var(--border)] text-[color:var(--clinical-accent)] focus:ring-[color:var(--focus)] h-4 w-4" />
- {showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} + {showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} - {activeModeResultKind === "answer" && answer && ( - - )} - {(documentsDrawerOpen || uploadDrawerOpen) && ( -
- - {documentsDrawerOpen ? ( - - + ) : null} +
+ )} - {(documentsDrawerOpen || uploadDrawerOpen) && } -
+ {(documentsDrawerOpen || uploadDrawerOpen) && } +
+
{anchor.label} @@ -582,11 +582,11 @@ function PinnedSourceEvidence({ data-testid="pinned-source-evidence" className={cn( sourceCard, - "scroll-mt-24 border-[color:var(--primary)]/20 bg-[color:var(--surface-raised)] p-3 shadow-[var(--shadow-tight)]", + "scroll-mt-24 border-[color:var(--clinical-accent)]/20 bg-[color:var(--surface-raised)] p-3 shadow-[var(--shadow-tight)]", )} >
- +
@@ -614,13 +614,13 @@ function PinnedSourceEvidence({ data-testid="highlighted-source-passage" className={cn( sourceCard, - "mt-3 overflow-hidden border-[color:var(--primary)] bg-[color:var(--surface)] shadow-[var(--glow-soft)] ring-2 ring-[color:var(--primary)]/20", + "mt-3 overflow-hidden border-[color:var(--clinical-accent)] bg-[color:var(--surface)] shadow-[var(--glow-soft)] ring-2 ring-[color:var(--clinical-accent)]/20", compact ? "text-sm leading-6" : "text-base-minus leading-7", )} > -
+
-

+

Highlighted source passage

@@ -634,7 +634,7 @@ function PinnedSourceEvidence({

Excerpt

-

+

{visibleContent || "No displayable clinical text was available for this indexed passage."}

@@ -663,7 +663,7 @@ function PinnedSourceEvidence({
) : ( -

+

Open a citation from an answer to see the exact indexed passage.

)} @@ -692,14 +692,14 @@ function IndexedSourceText({ return block.level === "title" ? (

{block.text}

) : (

{block.text}

@@ -741,7 +741,7 @@ function IndexedSourceText({

@@ -772,7 +772,7 @@ function HighlightedSearchText({ text, terms }: { text: string; terms: string[] terms.some((term) => part.toLowerCase() === term.toLowerCase()) ? ( {part} @@ -955,7 +955,7 @@ function IndexedTextPanel({ sourceCard, "overflow-hidden p-0 transition", (selectedChunkId === chunk.id || activeHit?.id === chunk.id) && - "border-[color:var(--primary)] bg-[color:var(--primary-soft)] shadow-[var(--glow-soft)] ring-2 ring-[color:var(--primary)]/25", + "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] shadow-[var(--glow-soft)] ring-2 ring-[color:var(--clinical-accent)]/25", )} >

@@ -963,9 +963,9 @@ function IndexedTextPanel({ className={cn( "mb-2 inline-flex min-h-6 items-center rounded-md px-2 text-xs font-bold", selectedChunkId === chunk.id - ? "bg-[color:var(--primary)] text-[color:var(--primary-contrast)]" + ? "bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]" : activeHit?.id === chunk.id - ? "bg-[color:var(--primary-soft)] text-[color:var(--primary)]" + ? "bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" : "border border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]", )} > @@ -987,7 +987,7 @@ function IndexedTextPanel({ {chunk.matchedTerms.slice(0, 5).map((term) => ( {term} @@ -1000,7 +1000,7 @@ function IndexedTextPanel({ Excerpt

{normalizedSearch ? ( -

+

) : (

- + {error ? "Page unavailable" : "Loading pages"} {error ? "Unavailable" : "Loading"}
@@ -1309,7 +1309,7 @@ function PdfCanvasViewer({ url, title, initialPage }: { url: string; title: stri "inline-flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-[var(--radius-md)] border px-3 text-xs font-semibold transition", "disabled:cursor-not-allowed disabled:opacity-45", fitWidth || fullscreenActive - ? "border-[color:var(--primary)]/35 bg-[color:var(--primary-soft)] text-[color:var(--primary)]" + ? "border-[color:var(--clinical-accent)]/35 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)] hover:bg-[color:var(--surface-subtle)]", )} > @@ -1355,14 +1355,14 @@ function PdfCanvasViewer({ url, title, initialPage }: { url: string; title: stri {(loading || rendering) && (
- + {loading ? "Loading PDF" : "Rendering page"} Source PDF @@ -1520,7 +1520,7 @@ function DocumentManualTagEditor({

- + Manual tags

@@ -2386,7 +2386,7 @@ export function DocumentViewer({ Documents @@ -2587,7 +2587,7 @@ export function DocumentViewer({
- + @@ -2620,9 +2620,9 @@ export function DocumentViewer({
{effectiveLoadingDocument ? ( -
+
- +

Preparing PDF preview

-
+
- )} -
+ + )} +
-
- - - - - - - - - - - - - - - - - - - - -
+
- - ); - } + {sidebarToolItems.map((item) => { + const Icon = item.icon; + const active = activeMode === item.id; + return ( + + + + ); + })} +
+ + + ); +} +export function ClinicalDesktopSidebar({ + collapsed, + collapseLocked = false, + recentQueries, + identity, + activeMode, + onCollapsedChange, + onNewChat, + onPickRecent, + onOpenGuide, + onOpenSettings, + onOpenAccount, + theme, + onToggleTheme, + onPrefetchApplications, +}: { + collapsed: boolean; + collapseLocked?: boolean; + recentQueries: string[]; + identity: SidebarIdentity; + activeMode: AppModeId; + onCollapsedChange: (collapsed: boolean) => void; + onNewChat: () => void; + onPickRecent: (query: string) => void; + onOpenGuide: () => void; + onOpenSettings: () => void; + onOpenAccount: () => void; + theme: ResolvedTheme; + onToggleTheme: () => void; + onPrefetchApplications: () => void; +}) { return ( - + {!collapsed ? ( + + ) : null} + ); } @@ -504,6 +547,7 @@ export function ClinicalMobileSidebar({ theme, onToggleTheme, onPrefetchApplications, + hiddenFrom = "md", }: { open: boolean; recentQueries: string[]; @@ -518,6 +562,8 @@ export function ClinicalMobileSidebar({ theme: ResolvedTheme; onToggleTheme: () => void; onPrefetchApplications: () => void; + /** Breakpoint the drawer disappears at; workflow routes keep it until lg. */ + hiddenFrom?: "md" | "lg"; }) { return ( } > (["env", "project", "schema", "search", "openai"]); +const requiredPublicSearchConfigCheckIds = new Set(["env", "project", "schema", "openai"]); export function hasReadyPublicSearchSetup(checks: SetupCheck[]) { return Array.from(publicSearchSetupCheckIds).every( @@ -104,6 +105,12 @@ export function hasReadyPublicSearchSetup(checks: SetupCheck[]) { ); } +export function hasReadyRequiredPublicSearchConfig(checks: SetupCheck[]) { + return Array.from(requiredPublicSearchConfigCheckIds).every( + (id) => checks.find((check) => check.id === id)?.status === "ready", + ); +} + function setupBadgeClasses(status: SetupCheckStatus) { if (status === "ready") { return toneSuccess; diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 14fef8bcc..ff148551d 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -171,29 +171,29 @@ export function ScopeAndGovernanceNotice({ scope?.matchedDocumentCount === 0; if (!showScope && groupedWarnings.length === 0) return null; return ( -
+
{showScope && scope ? ( -

+

Scope: {scope.summary} {scope.queryMode && scope.queryMode !== "auto" ? ` · ${scope.queryMode.replaceAll("_", " ")}` : ""}

) : null} {scope?.warnings?.length ? ( -
    +
      {scope.warnings.slice(0, 3).map((warning) => (
    • {warning}
    • ))}
    ) : null} {groupedWarnings.length ? ( -
      +
        {groupedWarnings.map((warning) => (
      • {warning.message} {warning.titles.length ? ( -
        +
        Sources affected - {warning.titles.slice(0, 5).join(", ")} + {warning.titles.slice(0, 5).join(", ")}
        ) : null}
      • @@ -546,6 +546,7 @@ export function NaturalLanguageAnswer({ onCopy: () => void; }) { const [sourcePreviewOpen, setSourcePreviewOpen] = useState(false); + const [sourceOnlyNoticeOpen, setSourceOnlyNoticeOpen] = useState(false); const [copiedSourceQuote, setCopiedSourceQuote] = useState(false); const sourceCapsuleRef = useRef(null); const copySourceQuoteTimerRef = useRef(null); @@ -617,17 +618,44 @@ export function NaturalLanguageAnswer({

        {sourceOnly ? ( -

        - Source-only answer — assembled from your documents without the AI model, so it may be less complete. Verify - it against the cited passages below. -

        + + {sourceOnlyNoticeOpen ? ( +
        +

        + This answer was assembled from your documents without the AI model, so it may be less complete. Verify + dose, threshold, route, timing, monitoring, and risk details against the cited passages below. +

        +
        + ) : null} + ) : null} {sourceCapsuleButton} {sourcePreviewOpen && canOpenSourcePreview && !usePreviewSheet ? ( @@ -695,7 +723,6 @@ export function UserQuestionBubble({ query }: { query: string }) { className="ml-auto max-w-[min(28rem,86%)] rounded-lg border border-[color:var(--border)] bg-[color:var(--clinical-accent-soft)] px-3 py-2 text-right shadow-[var(--shadow-inset)] sm:max-w-[28rem]" >

        {cleaned}

        -

        9:14 AM

); diff --git a/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx b/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx new file mode 100644 index 000000000..5ed3f800a --- /dev/null +++ b/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Sparkles } from "lucide-react"; + +import { cn, subtleStatusPill } from "@/components/ui-primitives"; + +export function AnswerFollowUpSuggestions({ + suggestions, + onPick, + disabled = false, +}: { + suggestions: string[]; + onPick: (suggestion: string) => void; + disabled?: boolean; +}) { + if (!suggestions.length) return null; + + return ( +
+
+ + +
+
+ {suggestions.map((suggestion) => ( + + ))} +
+
+ ); +} diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx new file mode 100644 index 000000000..7f094a426 --- /dev/null +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -0,0 +1,368 @@ +"use client"; + +import Link from "next/link"; +import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { ClipboardCheck, ExternalLink, Layers, ShieldAlert } from "lucide-react"; + +import { type AnswerFeedbackType } from "@/lib/answer-feedback"; +import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; +import { NaturalLanguageAnswer, UserQuestionBubble } from "@/components/clinical-dashboard/answer-content"; +import { + AnswerSupportSummaryCard, + answerHasCentralTable, + answerSupportPriority, + ClinicalNotesChecklistPanel, + clinicalNotesCount, + clinicalNotesDisplayCountForAnswer, + compactEvidenceSummary, + type EvidenceTabName, + formatQuoteCardsForClipboard, + primaryVisualTable, + SafetyFindingsListContent, +} from "@/components/clinical-dashboard/evidence-panels"; +import { InlineTableCard, MobileEvidenceSheetContent } from "@/components/clinical-dashboard/visual-evidence"; +import { Sheet } from "@/components/ui/sheet"; +import { answerSurface, cn, iconTilePremium, subtleStatusPill } from "@/components/ui-primitives"; +import { type AnswerRenderModel } from "@/lib/answer-render-policy"; +import { extractSafetyFindings } from "@/lib/clinical-safety"; +import { type SourceGovernanceWarning } from "@/lib/source-governance"; +import type { + AnswerSection, + BestSourceRecommendation, + EvidenceSummary, + QuoteCard, + RagAnswer, + SearchResult, +} from "@/lib/types"; +import { type AnswerEvidenceMapRow, type AnswerViewMode } from "@/lib/ward-output"; + +export function StagedAnswerResultSurface({ + answer, + query, + safeAnswerText, + bestSource, + sourceGovernanceWarnings, + sourceSummary, + renderModel, + weakEvidence, + answerViewMode, + answerEvidenceMapRows, + onScopeDocument, + answerGrounded, + sources, + demoMode, + safeAnswerSections, + safetyFindings, + copiedAnswer, + pendingFeedback, + onCopyAnswer, + onSubmitFeedback, + onFollowUpQuote, + followUpSuggestions, + onPickFollowUpSuggestion, + followUpSuggestionsDisabled = false, +}: { + answer: RagAnswer; + query: string; + safeAnswerText: string; + bestSource: BestSourceRecommendation | null; + sourceGovernanceWarnings: SourceGovernanceWarning[]; + sourceSummary?: EvidenceSummary; + renderModel: AnswerRenderModel; + weakEvidence: boolean; + answerViewMode: AnswerViewMode; + answerEvidenceMapRows: AnswerEvidenceMapRow[]; + onScopeDocument: (documentId: string) => void; + answerGrounded: boolean; + sources: SearchResult[]; + demoMode: boolean; + safeAnswerSections: Array; + safetyFindings: ReturnType; + copiedAnswer: boolean; + pendingFeedback: AnswerFeedbackType | null; + onCopyAnswer: () => void; + onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; + onFollowUpQuote?: (quote: QuoteCard) => void; + followUpSuggestions?: string[]; + onPickFollowUpSuggestion?: (suggestion: string) => void; + followUpSuggestionsDisabled?: boolean; +}) { + const noteCount = clinicalNotesCount(answer); + const showClinicalNotes = + safetyFindings.length > 0 || + noteCount > 0 || + answer.answerQualityTier === "source_only" || + answerGrounded === false; + const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( + answer, + answerViewMode, + noteCount || safetyFindings.length, + ); + const sourceCount = + renderModel.primarySources.length || + sourceSummary?.total_sources || + sources.length || + answer.sources?.length || + answer.citations.length; + const centralTable = answerHasCentralTable(answer) ? primaryVisualTable(answer) : null; + const showEvidenceDrawer = renderModel.allowedBlocks.some((block) => + ["sourceStatus", "reviewSources", "evidenceMap", "quoteCards", "visualEvidence", "warnings"].includes(block), + ); + const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); + const [evidenceOpen, setEvidenceOpen] = useState(false); + const [safetyFindingsOpen, setSafetyFindingsOpen] = useState(false); + const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); + const [copiedQuotes, setCopiedQuotes] = useState(false); + const clinicalNotesTriggerRef = useRef(null); + const evidenceTriggerRef = useRef(null); + const safetyTriggerRef = useRef(null); + const copyQuotesTimerRef = useRef(null); + useEffect(() => { + return () => { + if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); + }; + }, []); + function openClinicalNotes() { + setEvidenceOpen(false); + setSafetyFindingsOpen(false); + setEvidenceInitialTab(null); + setClinicalNotesOpen(true); + } + function restoreFocusToTrigger(ref: RefObject) { + window.requestAnimationFrame(() => { + if (ref.current?.isConnected) ref.current.focus({ preventScroll: true }); + }); + } + function closeClinicalNotesReview() { + setClinicalNotesOpen(false); + restoreFocusToTrigger(clinicalNotesTriggerRef); + } + function openEvidence(initialTab: EvidenceTabName | null = null) { + setClinicalNotesOpen(false); + setSafetyFindingsOpen(false); + setEvidenceInitialTab(initialTab); + setEvidenceOpen(true); + } + function closeEvidenceReview() { + setEvidenceOpen(false); + setEvidenceInitialTab(null); + restoreFocusToTrigger(evidenceTriggerRef); + } + function openTableEvidence() { + setClinicalNotesOpen(false); + setSafetyFindingsOpen(false); + openEvidence("Tables"); + } + function openSafetyFindings() { + setClinicalNotesOpen(false); + setEvidenceOpen(false); + setEvidenceInitialTab(null); + setSafetyFindingsOpen(true); + } + function closeSafetyFindingsReview() { + setSafetyFindingsOpen(false); + restoreFocusToTrigger(safetyTriggerRef); + } + const copyQuotes = useCallback(async () => { + const quoteText = formatQuoteCardsForClipboard(renderModel.quoteCards); + if (!quoteText) return; + try { + await navigator.clipboard.writeText(quoteText); + setCopiedQuotes(true); + if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); + copyQuotesTimerRef.current = window.setTimeout(() => setCopiedQuotes(false), 1600); + } catch { + setCopiedQuotes(false); + } + }, [renderModel.quoteCards]); + const priority = answerSupportPriority(answer, safeAnswerSections, centralTable, safetyFindings, { + grounded: answerGrounded, + weakEvidence, + }); + const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); + const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; + const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); + const showLayoutAside = Boolean(centralTable); + + return ( +
+
+ + +
+
+ + + {showInlineSupportCard ? ( + openEvidence(null)} + onOpenSafetyFindings={safetyFindings.length > 0 ? openSafetyFindings : undefined} + /> + ) : null} + + {followUpSuggestions?.length && onPickFollowUpSuggestion ? ( + + ) : null} +
+ + {centralTable ? ( +
+ +
+ ) : null} +
+ + {showClinicalNotes ? ( + + + + } + titleAccessory={ + + {clinicalNoteDisplayCount} + + } + headerActions={ + bestSource ? ( + + + + ) : null + } + headerClassName="gap-2 p-2.5 sm:p-3" + titleClassName="text-[15px] leading-5" + closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-md" + bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={clinicalNotesTriggerRef} + portal + > + + + ) : null} + + {showEvidenceDrawer ? ( + {evidenceTrustLabel} + } + closeLabel="Close evidence" + headerLeading={ + + + + } + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl" + bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={evidenceTriggerRef} + portal + > + + + ) : null} + + {safetyFindings.length > 0 ? ( + + + + } + titleAccessory={ + + {safetyFindings.length} + + } + headerClassName="gap-2 p-2.5 sm:p-3" + titleClassName="text-[15px] leading-5" + closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" + contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(80dvh,36rem)] sm:max-w-lg" + bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={safetyTriggerRef} + portal + > + + + ) : null} +
+
+ ); +} diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 5feef40f4..6fcbb6226 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; import { Activity, BrainCircuit, @@ -25,8 +26,10 @@ import { } from "lucide-react"; import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; +import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; +import { differentialsMobileCompareAddonSlotId } from "@/lib/mode-home-composer"; import { acuteConfusionPresentationWorkflow, getDifferentialRecord, @@ -114,6 +117,40 @@ function routeWithQuery(path: string, query: string) { return suffix ? `${path}?${suffix}` : path; } +function DifferentialsMobileCompareAddon({ selectedCount, query }: { selectedCount: number; query: string }) { + const [host, setHost] = useState(null); + + useEffect(() => { + const phoneMediaQuery = window.matchMedia("(max-width: 1023px)"); + const sync = () => { + setHost(phoneMediaQuery.matches ? document.getElementById(differentialsMobileCompareAddonSlotId) : null); + }; + sync(); + phoneMediaQuery.addEventListener("change", sync); + const observer = new MutationObserver(sync); + observer.observe(document.body, { childList: true, subtree: true }); + return () => { + phoneMediaQuery.removeEventListener("change", sync); + observer.disconnect(); + }; + }, []); + + if (!host) return null; + + return createPortal( + + + Compare selected ({selectedCount}) + + , + host, + ); +} + function statusLabel(status: DifferentialRecord["status"]) { if (status === "emergent") return "Emergent"; if (status === "urgent") return "High"; @@ -651,8 +688,20 @@ function SearchResultsView({ return (
+
+ +
+
+ +

-
- - - Compare selected ({selectedCount}) - - -
+

Clinical decision support only. Review before use. diff --git a/src/components/clinical-dashboard/document-results.tsx b/src/components/clinical-dashboard/document-results.tsx index e3863a08e..f5aa15bd5 100644 --- a/src/components/clinical-dashboard/document-results.tsx +++ b/src/components/clinical-dashboard/document-results.tsx @@ -1,129 +1,17 @@ "use client"; import Link from "next/link"; -import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; -import { BookOpen, ChevronDown, ClipboardCheck, ExternalLink, Layers, Search, X } from "lucide-react"; +import { BookOpen } from "lucide-react"; import { DocumentOrganizationBadges, documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { SafeBoldText } from "@/components/SafeBoldText"; -import { Sheet } from "@/components/ui/sheet"; -import { type AnswerFeedbackType } from "@/components/ClinicalDashboard"; -import { NaturalLanguageAnswer, UserQuestionBubble } from "@/components/clinical-dashboard/answer-content"; -import { StrengthBadge } from "@/components/clinical-dashboard/badges"; import { UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; -import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text"; -import { MatchExplanationChips } from "@/components/clinical-dashboard/document-search-results"; -import { - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - formatQuoteCardsForClipboard, - primaryVisualTable, - SafetyFindingsPanel, -} from "@/components/clinical-dashboard/evidence-panels"; -import { QueryCoverageChips, RelevanceBadge } from "@/components/clinical-dashboard/relevance"; -import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; -import { InlineTableCard, MobileEvidenceSheetContent } from "@/components/clinical-dashboard/visual-evidence"; -import { - answerSurface, - cn, - floatingControl, - iconTilePremium, - panelSubtle, - sourceCard, - SourceStatusBadge, - subtleStatusPill, - textMuted, -} from "@/components/ui-primitives"; -import { type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { extractSafetyFindings } from "@/lib/clinical-safety"; +import { cn, floatingControl, sourceCard, textMuted } from "@/components/ui-primitives"; import { type SmartDocumentTag } from "@/lib/document-tags"; -import { type SourceGovernanceWarning } from "@/lib/source-governance"; -import type { - AnswerSection, - BestSourceRecommendation, - ClinicalQueryMode, - ConflictOrGap, - EvidenceRelevance, - EvidenceSummary, - RagAnswer, - RelatedDocument, - SearchResult, - SearchScopeSummary, -} from "@/lib/types"; -import { type AnswerEvidenceMapRow, type AnswerViewMode } from "@/lib/ward-output"; +import type { RelatedDocument } from "@/lib/types"; -function WhyThisMatchedPanel({ sources }: { sources: SearchResult[] }) { - const visibleSources = sources.slice(0, 3); - if (visibleSources.length === 0) return null; - - return ( -

- - - - - - - Why this matched - - Match signals, source strength, and term coverage for top passages - - - - - -
- {visibleSources.map((source) => ( -
-
-
-

- {cleanDisplayTitle(source.title)} -

-

- page {source.page_number ?? "n/a"} ·{" "} - chunk {source.chunk_index} -

-
-
- - - -
-
- - {source.index_unit ? ( -

- - {source.index_unit.unit_type.replaceAll("_", " ")}: - {" "} - {source.index_unit.title} -

- ) : null} -
- -
-
- ))} -
-
- ); -} +export { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; export function RelatedDocumentsPanel({ documents, @@ -150,7 +38,7 @@ export function RelatedDocumentsPanel({
{documentDisplayTitle(document)} @@ -163,7 +51,7 @@ export function RelatedDocumentsPanel({ @@ -180,371 +68,3 @@ export function RelatedDocumentsPanel({ ); } - -export function StagedAnswerResultSurface({ - answer, - query, - safeAnswerText, - bestSource, - currentRelevance, - queryMode, - sourceGovernanceWarnings, - sourceSummary, - renderModel, - weakEvidence, - groupedGovernanceWarningCount, - answerViewMode, - answerEvidenceMapRows, - onScopeDocument, - answerGrounded, - sources, - gaps, - searchScope, - demoMode, - safeAnswerSections, - safetyFindings, - copiedAnswer, - pendingFeedback, - onCopyAnswer, - onSubmitFeedback, -}: { - answer: RagAnswer; - query: string; - safeAnswerText: string; - bestSource: BestSourceRecommendation | null; - currentRelevance: EvidenceRelevance | null | undefined; - queryMode: ClinicalQueryMode; - sourceGovernanceWarnings: SourceGovernanceWarning[]; - sourceSummary?: EvidenceSummary; - renderModel: AnswerRenderModel; - weakEvidence: boolean; - groupedGovernanceWarningCount: number; - answerViewMode: AnswerViewMode; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - onScopeDocument: (documentId: string) => void; - answerGrounded: boolean; - sources: SearchResult[]; - gaps: ConflictOrGap[]; - searchScope: SearchScopeSummary | null; - demoMode: boolean; - safeAnswerSections: Array; - safetyFindings: ReturnType; - copiedAnswer: boolean; - pendingFeedback: AnswerFeedbackType | null; - onCopyAnswer: () => void; - onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; -}) { - const noteCount = clinicalNotesCount(answer); - const showClinicalNotes = safetyFindings.length > 0 || noteCount > 0; - const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( - answer, - answerViewMode, - noteCount || safetyFindings.length, - ); - const sourceCount = - renderModel.primarySources.length || - sourceSummary?.total_sources || - sources.length || - answer.sources?.length || - answer.citations.length; - const centralTable = answerHasCentralTable(answer) ? primaryVisualTable(answer) : null; - const showEvidenceDrawer = renderModel.allowedBlocks.some((block) => - ["sourceStatus", "reviewSources", "evidenceMap", "quoteCards", "visualEvidence", "warnings"].includes(block), - ); - const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); - const [evidenceOpen, setEvidenceOpen] = useState(false); - const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); - const [activeReviewPanel, setActiveReviewPanel] = useState<"clinical" | "evidence" | null>(null); - const [copiedQuotes, setCopiedQuotes] = useState(false); - const clinicalNotesTriggerRef = useRef(null); - const evidenceTriggerRef = useRef(null); - const useReviewSheet = useMobilePreviewSheet(); - const copyQuotesTimerRef = useRef(null); - useEffect(() => { - return () => { - if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); - }; - }, []); - function openClinicalNotes() { - setEvidenceOpen(false); - setEvidenceInitialTab(null); - if (useReviewSheet) { - setActiveReviewPanel(null); - setClinicalNotesOpen(true); - return; - } - setClinicalNotesOpen(false); - setActiveReviewPanel("clinical"); - } - function restoreFocusToTrigger(ref: RefObject) { - window.requestAnimationFrame(() => { - if (ref.current?.isConnected) ref.current.focus({ preventScroll: true }); - }); - } - function closeClinicalNotesReview() { - setClinicalNotesOpen(false); - restoreFocusToTrigger(clinicalNotesTriggerRef); - } - function openEvidence(initialTab: EvidenceTabName | null = null) { - setClinicalNotesOpen(false); - setEvidenceInitialTab(initialTab); - if (useReviewSheet) { - setActiveReviewPanel(null); - setEvidenceOpen(true); - return; - } - setEvidenceOpen(false); - setActiveReviewPanel("evidence"); - } - function closeEvidenceReview() { - setEvidenceOpen(false); - setEvidenceInitialTab(null); - restoreFocusToTrigger(evidenceTriggerRef); - } - function closeDesktopReviewPanel() { - const triggerRef = activeReviewPanel === "clinical" ? clinicalNotesTriggerRef : evidenceTriggerRef; - setActiveReviewPanel(null); - restoreFocusToTrigger(triggerRef); - } - function openTableEvidence() { - setClinicalNotesOpen(false); - openEvidence("Tables"); - } - const copyQuotes = useCallback(async () => { - const quoteText = formatQuoteCardsForClipboard(renderModel.quoteCards); - if (!quoteText) return; - try { - await navigator.clipboard.writeText(quoteText); - setCopiedQuotes(true); - if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); - copyQuotesTimerRef.current = window.setTimeout(() => setCopiedQuotes(false), 1600); - } catch { - setCopiedQuotes(false); - } - }, [renderModel.quoteCards]); - const priority = answerSupportPriority(answer, safeAnswerSections, centralTable, safetyFindings, { - grounded: answerGrounded, - weakEvidence, - }); - const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); - const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; - const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); - const showLayoutAside = Boolean(activeReviewPanel || centralTable); - - return ( -
-
- - -
-
- - - {showInlineSupportCard ? ( - openEvidence(null)} - /> - ) : null} - - {centralTable && activeReviewPanel ? : null} -
- - {activeReviewPanel ? ( - - ) : centralTable ? ( -
- -
- ) : null} -
- - {showClinicalNotes ? ( - - - - } - titleAccessory={ - - {clinicalNoteDisplayCount} - - } - headerActions={ - bestSource ? ( - - - - ) : null - } - headerClassName="gap-2 p-2.5 sm:p-3" - titleClassName="text-[15px] leading-5" - closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - contentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" - contentStyle={{ height: "80dvh" }} - bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={clinicalNotesTriggerRef} - portal - > - - - ) : null} - - {showEvidenceDrawer ? ( - {evidenceTrustLabel} - } - closeLabel="Close evidence" - headerLeading={ - - - - } - contentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" - contentStyle={{ height: "80dvh" }} - bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" - returnFocusRef={evidenceTriggerRef} - portal - > - - - ) : null} -
- - -
- ); -} diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index ab49bbc95..6809fb1d1 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import { - AlertCircle, BookOpen, ChevronDown, Clock3, @@ -26,6 +25,7 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { ModeHomeTemplate } from "@/components/mode-home-template"; +import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { SafeBoldText } from "@/components/SafeBoldText"; import { DocumentActionButton, @@ -44,6 +44,8 @@ import { sourceCard, textMuted, } from "@/components/ui-primitives"; + +const disabledControlClass = "cursor-not-allowed opacity-60"; import { buildSmartDocumentTagFacetIndex, filterDocumentsBySmartTagFacetIndex, @@ -165,7 +167,7 @@ function DocumentTagFacetRail({ return (

- + {group}

@@ -181,7 +183,7 @@ function DocumentTagFacetRail({ className={cn( "inline-flex min-h-7 max-w-full items-center gap-1 rounded-md border px-2 text-2xs font-semibold shadow-[var(--shadow-inset)] transition", selected - ? "border-[color:var(--primary)]/35 bg-[color:var(--primary-soft)] text-[color:var(--primary)]" + ? "border-[color:var(--clinical-accent)]/35 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]", )} > @@ -342,9 +344,11 @@ function DocumentSearchHome({ onClick: item.action, }))} footer={ -

- {documentCount.toLocaleString()} indexed source{documentCount === 1 ? "" : "s"} -

+ documentCount > 0 ? ( +

+ {documentCount.toLocaleString()} indexed source{documentCount === 1 ? "" : "s"} +

+ ) : null } /> ); @@ -373,9 +377,12 @@ function SearchResultsHeader({ resultLabel, trimmedQuery }: { resultLabel: strin
@@ -1001,7 +1012,7 @@ export function DocumentSearchResultsPanel({

{documentDisplayTitle(document)} diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index bbe3efd66..46dfd4a4e 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -27,7 +27,7 @@ import { } from "lucide-react"; import { AccessibleTable } from "@/components/AccessibleTable"; -import { type AnswerFeedbackType } from "@/components/ClinicalDashboard"; +import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { ClinicalOutputPanel } from "@/components/clinical-dashboard/output-panel"; import { keyClinicalItemsFromSections, @@ -68,7 +68,13 @@ import { } from "@/components/ui-primitives"; import { type AnswerRenderModel, type SourceLink } from "@/lib/answer-render-policy"; import { documentCitationHref, formatCitationLabel, formatCompactCitationLabel } from "@/lib/citations"; -import { extractSafetyFindings, formatSafetyFindingLabel } from "@/lib/clinical-safety"; +import { + extractSafetyFindings, + formatSafetyFindingLabel, + sortSafetyFindingsBySeverity, + type SafetyFinding, + type SafetyFindingKind, +} from "@/lib/clinical-safety"; import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; import { normalizeExtractedGlyphs, @@ -106,12 +112,11 @@ export function answerSupportPriority( safetyFindings: ReturnType, options: { grounded: boolean; weakEvidence: boolean }, ): AnswerSupportPriority | null { - const firstSafetyFinding = safetyFindings[0]; + const firstSafetyFinding = sortSafetyFindingsBySeverity(safetyFindings)[0]; if (firstSafetyFinding) { return { - title: "Priority", + title: "Safety findings", detail: formatSafetyFindingLabel(firstSafetyFinding), - sourceLabel: "S1", tone: "caution", }; } @@ -147,8 +152,11 @@ export function AnswerSupportSummaryCard({ evidenceAvailable, clinicalTriggerRef, evidenceTriggerRef, + safetyTriggerRef, + safetyFindingsCount = 0, onOpenClinicalNotes, onOpenEvidence, + onOpenSafetyFindings, }: { priority: AnswerSupportPriority | null; clinicalCount: number; @@ -157,12 +165,16 @@ export function AnswerSupportSummaryCard({ evidenceAvailable: boolean; clinicalTriggerRef?: RefObject; evidenceTriggerRef?: RefObject; + safetyTriggerRef?: RefObject; + safetyFindingsCount?: number; onOpenClinicalNotes: () => void; onOpenEvidence: () => void; + onOpenSafetyFindings?: () => void; }) { const supportRowCount = Number(clinicalAvailable) + Number(evidenceAvailable); const supportButtonClass = - "grid min-h-[72px] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-3 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; + "grid min-h-[60px] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2.5 px-3 py-2.5 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; + const safetyInteractive = Boolean(onOpenSafetyFindings && safetyFindingsCount > 0); return (
{priority ? ( -
- -
-

{priority.title}

-

{priority.detail}

+ + + {priority.title} + {priority.detail} + + + {safetyFindingsCount} + + + + ) : ( +
+ +
+

{priority.title}

+

{priority.detail}

+
+ {priority.sourceLabel ? ( + {priority.sourceLabel} + ) : null}
- {priority.sourceLabel ? ( - {priority.sourceLabel} - ) : null} -
+ ) ) : null} {supportRowCount > 0 ? ( @@ -210,7 +254,7 @@ export function AnswerSupportSummaryCard({ className={supportButtonClass} aria-label="Open clinical notes" > - + Clinical notes @@ -230,7 +274,7 @@ export function AnswerSupportSummaryCard({ className={supportButtonClass} aria-label="Open evidence" > - + Evidence {evidenceSummary} @@ -377,8 +421,17 @@ type ClinicalNotesRow = { detail: string; sourceIndex: number; tone: "safe" | "warn"; + href?: string; }; +function clinicalNoteHref( + sourceIndex: number, + sourceLinks: SourceLink[], + bestSource: BestSourceRecommendation | null, +): string | undefined { + return sourceLinks[sourceIndex - 1]?.href ?? sourceLinks[0]?.href ?? bestSource?.viewer_href ?? undefined; +} + const clinicalNotesTabMeta: Record< ClinicalNotesTabId, { label: string; icon: typeof ShieldCheck; sectionIds: string[] } @@ -566,26 +619,34 @@ function clinicalNotesTableEvidenceCount(answer: RagAnswer) { ).length; } -function clinicalNotesRowsForTab(sections: ClinicalDetailSection[], tab: ClinicalNotesTabId) { +function clinicalNotesRowsForTab( + sections: ClinicalDetailSection[], + tab: ClinicalNotesTabId, + sourceLinks: SourceLink[] = [], + bestSource: BestSourceRecommendation | null = null, +) { const meta = clinicalNotesTabMeta[tab]; const rows: ClinicalNotesRow[] = []; let sourceIndex = 1; for (const section of sections) { const sectionText = `${section.title} ${section.items.join(" ")}`.toLowerCase(); + const isVerifySourceReview = section.id === "verify-source"; + if (isVerifySourceReview && tab !== "safety") continue; const hasMonitoringText = (tab === "actions" || tab === "essentials") && /\b(monitor|screen|level|fbc|anc|metabolic|renal|thyroid|function)\b/i.test(sectionText); const hasSafetyText = tab === "safety" && /\b(toxicity|toxic|urgent|caution|contraindication|red flag|escalat|warning|review due)\b/i.test(sectionText); - if (!meta.sectionIds.includes(section.id) && !hasMonitoringText) { + if (!isVerifySourceReview && !meta.sectionIds.includes(section.id) && !hasMonitoringText) { if (!hasSafetyText) continue; } if (tab === "essentials" && section.id === "action" && rows.length >= 2) { continue; } - const tone: ClinicalNotesRow["tone"] = section.id === "escalation" || section.id === "cautions" ? "warn" : "safe"; + const tone: ClinicalNotesRow["tone"] = + section.id === "escalation" || section.id === "cautions" || isVerifySourceReview ? "warn" : "safe"; for (const item of section.items.slice(0, 4)) { if (section.tables?.length && /\b(table|showing domains|table showing)\b/i.test(item)) continue; @@ -594,21 +655,25 @@ function clinicalNotesRowsForTab(sections: ClinicalDetailSection[], tab: Clinica if (fragments) { for (const fragment of fragments) { const fragmentTitle = clinicalNoteTitleFromFragment(fragment); + const currentSourceIndex = sourceIndex; rows.push({ id: `${tab}:${section.id}:${rows.length}:${fragmentTitle}`, title: fragmentTitle, detail: fragment, sourceIndex: sourceIndex++, tone: clinicalNoteToneForText(fragment, tone), + href: clinicalNoteHref(currentSourceIndex, sourceLinks, bestSource), }); } } else { + const currentSourceIndex = sourceIndex; rows.push({ id: `${tab}:${section.id}:${rows.length}:${title}`, title, detail: clinicalNoteDetailFromItem(item, title), sourceIndex: sourceIndex++, tone: clinicalNoteToneForText(item, tone), + href: clinicalNoteHref(currentSourceIndex, sourceLinks, bestSource), }); } } @@ -627,9 +692,10 @@ function clinicalNotesDetailSectionsForAnswer(answer: RagAnswer, viewMode: Answe const sections = viewMode === "high_yield" ? buildHighYieldClinicalOutputSections(answer) : buildClinicalOutputSections(answer); const primaryAnswer = plainAnswerText(answer.answer); + const keepVerifySource = answer.answerQualityTier === "source_only" || answer.grounded === false; return sortClinicalDetailSections( sections - .filter((section) => section.id !== "verify-source" && section.id !== "bottom-line") + .filter((section) => (keepVerifySource || section.id !== "verify-source") && section.id !== "bottom-line") .map((section) => ({ ...section, items: displayItemsForClinicalDetailSection(section, primaryAnswer, false), @@ -648,6 +714,7 @@ export function ClinicalNotesChecklistPanel({ answer, viewMode, evidenceMapRows, + sourceLinks = [], bestSource, copied, onCopy, @@ -656,6 +723,7 @@ export function ClinicalNotesChecklistPanel({ answer: RagAnswer; viewMode: AnswerViewMode; evidenceMapRows: AnswerEvidenceMapRow[]; + sourceLinks?: SourceLink[]; bestSource: BestSourceRecommendation | null; copied: boolean; onCopy: () => void; @@ -666,10 +734,10 @@ export function ClinicalNotesChecklistPanel({ const defaultTab = tabs.find((tab) => tab.id === "actions")?.id ?? tabs[0]?.id ?? "actions"; const [requestedTab, setRequestedTab] = useState(defaultTab); const activeTab = tabs.some((tab) => tab.id === requestedTab) ? requestedTab : defaultTab; - const rows = clinicalNotesRowsForTab(detailSections, activeTab); + const rows = clinicalNotesRowsForTab(detailSections, activeTab, sourceLinks, bestSource); const tableEvidenceCount = clinicalNotesTableEvidenceCount(answer); const [added, setAdded] = useState(false); - const warningRows = clinicalNotesRowsForTab(detailSections, "safety"); + const warningRows = clinicalNotesRowsForTab(detailSections, "safety", sourceLinks, bestSource); const warningCount = warningRows.filter((row) => row.tone === "warn").length || warningRows.length; if (!tabs.length || rows.length === 0) { @@ -678,110 +746,132 @@ export function ClinicalNotesChecklistPanel({ ); } - const activeMeta = clinicalNotesTabMeta[activeTab]; + const showTabStrip = tabs.length > 1; return (
-
-
- {tabs.map((tab) => { - const selected = tab.id === activeTab; - return ( - - ); - })} + {tab.label} + + {tab.count} + + + ); + })} +
-
+ ) : null} -
-

- {activeMeta.label} ({rows.length}) -

- {tableEvidenceCount > 0 && onOpenTables ? ( + {tableEvidenceCount > 0 && onOpenTables ? ( +
- ) : null} -
+
+ ) : null} -
+
0 && onOpenTables) ? "mt-3" : "mt-0", + )} + > {rows.map((row) => { const hasDistinctDetail = clinicalNoteHasDistinctDetail(row); const RowIcon = row.tone === "warn" ? AlertCircle : activeTab === "actions" ? Activity : CheckCircle2; - return ( -
+ const isWarnRow = row.tone === "warn"; + const rowContent = ( + <>

{row.title}

- - {row.tone === "warn" ? "Review" : activeTab === "actions" ? "Action" : "Source"} - + {!isWarnRow ? ( + + {activeTab === "actions" ? "Action" : "Source"} + + ) : null}
{hasDistinctDetail ? ( -

{row.detail}

+

{row.detail}

) : null}
-
- - S{row.sourceIndex} - - +
+ {isWarnRow ? ( + Review + ) : ( + + S{row.sourceIndex} + + )} +
+ + ); + return row.href ? ( + + {rowContent} + + ) : ( +
+ {rowContent}
); })} @@ -840,54 +930,63 @@ export function ClinicalNotesChecklistPanel({ ); } -export function SafetyFindingsPanel({ findings }: { findings: ReturnType }) { +function safetyFindingKindTone(kind: SafetyFindingKind) { + return kind === "contraindication" || kind === "red_flag" ? toneDanger : toneWarning; +} + +function SafetyFindingRowIcon({ kind }: { kind: SafetyFindingKind }) { + if (kind === "contraindication" || kind === "red_flag") { + return ; + } + return ; +} + +export function SafetyFindingsListContent({ findings }: { findings: SafetyFinding[] }) { if (findings.length === 0) return null; + const sortedFindings = sortSafetyFindingsBySeverity(findings); + return ( -
- -
- {findings.map((finding, index) => ( -
( +
+
- ))} -
-
+

{finding.text}

+
+
+ ))} +
); } @@ -1309,25 +1408,22 @@ export function AnswerSafetyNotice({
-

+

{weakEvidence ? "Weak source support; verify the linked source before relying on this answer." : "Draft only; verify source first before pasting into the medical record."}

{retrievalGateBlocked ? ( -

- Retrieval confidence gate was triggered (low-confidence retrieval signal). Expand evidence details before - using this result. +

+ Retrieval confidence gate was triggered. Expand evidence details before using this result.

) : null} {demoMode ? ( -

+

Synthetic demo only: this is not clinical guidance.

) : null} diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 9c40ed1ac..c3665a18e 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -1,9 +1,8 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { - ArrowUpDown, - Check, ChevronDown, ChevronsRight, Copy, @@ -12,44 +11,64 @@ import { FileText, Folder, Heart, - LayoutGrid, - List, - MessageSquare, MoreVertical, Pill, - Pin, Quote, - Save, Search, ShieldCheck, Trash2, X, type LucideIcon, } from "lucide-react"; -import type { ComponentPropsWithoutRef } from "react"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; +import { + FavouritesMobileBrowseRail, + FavouritesSidebar, + useFavouritesNavCollapsed, + type FavouritesViewMode, +} from "@/components/clinical-dashboard/favourites-library-nav"; +import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { cn } from "@/components/ui-primitives"; +import { + favouriteItems as prototypeFavouriteItems, + favouriteSets as prototypeFavouriteSets, + favouriteTabs, + type FavouriteItem as PrototypeFavouriteItem, +} from "@/components/clinical-dashboard/favourites-prototype-data"; +import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; +import { + SearchResultsEmptyState, + SearchResultsHeaderBand, +} from "@/components/clinical-dashboard/search-results-header-band"; +import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; +import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; +import { appModeIcons } from "@/lib/app-mode-icons"; -type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source"; +type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form"; +type ViewMode = FavouritesViewMode; +type SortMode = "last-used" | "title" | "type"; type FavouriteItem = { id: string; title: string; description: string; type: FavouriteType; + tabId: string; set: string; evidence: string; lastUsed: string; action: string; href: string; icon: LucideIcon; - selected?: boolean; + pinned?: boolean; }; type FavouriteSet = { + id: string; title: string; count: number; + meta?: string; }; type SourceRecord = { @@ -60,78 +79,6 @@ type SourceRecord = { const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; -const favouriteSets: FavouriteSet[] = [ - { title: "Ward round", count: 2 }, - { title: "Prescribing safety", count: 2 }, - { title: "Clozapine clinic", count: 1 }, -]; - -const favouriteItems: FavouriteItem[] = [ - { - id: "acamprosate-renal-screen", - title: "Acamprosate renal screen", - description: "Medication page · renal cautions / dose notes", - type: "Medication", - set: "Ward round", - evidence: "3 sources", - lastUsed: "Today 08:44", - action: "Open", - href: "/medications/acamprosate", - icon: Pill, - selected: true, - }, - { - id: "lithium-monitoring-guideline", - title: "Lithium monitoring guideline", - description: "PDF · p.4-9 · 2 tables", - type: "Document", - set: "Prescribing safety", - evidence: "PDF verified", - lastUsed: "Today 08:20", - action: "Ask", - href: "/?mode=documents&q=lithium+monitoring&run=1", - icon: FileText, - }, - { - id: "clozapine-monitoring-table", - title: "Clozapine monitoring table", - description: "Saved table · ANC monitoring", - type: "Table", - set: "Clozapine clinic", - evidence: "Table verified", - lastUsed: "Yesterday 16:12", - action: "Open", - href: "/?mode=documents&q=clozapine+monitoring+table&run=1", - icon: Quote, - }, - { - id: "renal-dose-saved-search", - title: "renal dose saved search", - description: "Medicines plus documents / eGFR cautions", - type: "Saved search", - set: "Ward round", - evidence: "Saved query", - lastUsed: "Today 07:55", - action: "Run", - href: "/?mode=answer&q=renal+dose&run=1", - icon: Search, - }, - { - id: "qt-prolongation-quote", - title: "QT prolongation quote", - description: "Source card / prescribing safety", - type: "Source", - set: "Prescribing safety", - evidence: "2 sources", - lastUsed: "Mon 11:03", - action: "Copy", - href: "/?mode=documents&q=QT+prolongation&run=1", - icon: Quote, - }, -]; - -const selectedItem = favouriteItems[0]; - const sourceRecords: SourceRecord[] = [ { title: "NICE CKS - Alcohol dependence", type: "Guideline" }, { title: "BNF - Acamprosate", type: "BNF" }, @@ -141,12 +88,145 @@ const sourceRecords: SourceRecord[] = [ const typeStyles: Record = { Medication: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", - Document: "border-blue-200 bg-blue-50 text-blue-700", - Table: "border-emerald-200 bg-emerald-50 text-emerald-700", - "Saved search": "border-slate-200 bg-slate-50 text-slate-700", - Source: "border-violet-200 bg-violet-50 text-violet-700", + Document: + "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]", + Table: "border-[color:var(--type-table-border)] bg-[color:var(--type-table-soft)] text-[color:var(--type-table)]", + "Saved search": + "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]", + Source: "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]", + Service: + "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]", + Form: "border-[color:var(--type-form-border)] bg-[color:var(--type-form-soft)] text-[color:var(--type-form)]", +}; + +const lastUsedByItemId: Record = { + "acamprosate-renal-screen": "Today 08:44", + "lithium-monitoring-guideline": "Today 08:20", + "clozapine-monitoring-table": "Yesterday 16:12", + "renal-dose-search": "Today 07:55", + "qt-prolongation-quote": "Mon 11:03", +}; + +const pinnedItemIds = new Set(["acamprosate-renal-screen", "lithium-monitoring-guideline"]); + +const typeByPrototypeType: Record = { + medications: "Medication", + documents: "Document", + sources: "Source", + services: "Service", + forms: "Form", +}; + +const fallbackIconByType: Record = { + medications: Pill, + documents: FileText, + sources: Quote, + services: appModeIcons.services, + forms: FileText, }; +function lastUsedScore(lastUsed: string): number { + const lower = lastUsed.toLowerCase(); + if (lower.startsWith("today")) { + const timeMatch = lastUsed.match(/(\d{1,2}):(\d{2})/); + if (timeMatch) return 100_000 + Number(timeMatch[1]) * 60 + Number(timeMatch[2]); + return 100_000; + } + if (lower.startsWith("yesterday")) return 50_000; + if (lower.startsWith("mon")) return 10_000; + return 1_000; +} + +function isSourceBacked(item: FavouriteItem): boolean { + return Boolean(item.evidence && item.evidence !== "Run" && item.evidence !== "Saved query"); +} + +function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem { + const type = typeByPrototypeType[item.type] ?? (item.primaryAction === "Run" ? "Saved search" : "Source"); + return { + id: item.id, + title: item.title, + description: item.meta, + type, + tabId: item.type, + set: item.set || (item.type === "services" ? "Saved services" : item.type === "forms" ? "Saved forms" : "Unsorted"), + evidence: item.sourceMeta, + lastUsed: lastUsedByItemId[item.id] ?? "Saved", + action: item.primaryAction, + href: item.href, + icon: item.icon ?? fallbackIconByType[item.type], + pinned: pinnedItemIds.has(item.id), + }; +} + +function buildFavouriteSets(items: FavouriteItem[]): FavouriteSet[] { + const presetSets = prototypeFavouriteSets.map((set) => ({ + id: set.id, + title: set.title, + count: items.filter((item) => item.set === set.title).length, + meta: set.meta, + })); + const knownTitles = new Set(presetSets.map((set) => set.title)); + const dynamicSets = Array.from(new Set(items.map((item) => item.set))) + .filter((title) => title && !knownTitles.has(title)) + .map((title) => ({ + id: title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""), + title, + count: items.filter((item) => item.set === title).length, + })); + return [...presetSets, ...dynamicSets].filter((set) => set.count > 0); +} + +function getMostRecentlyUsedItem(items: FavouriteItem[]): FavouriteItem | null { + if (items.length === 0) return null; + return [...items].sort((first, second) => lastUsedScore(second.lastUsed) - lastUsedScore(first.lastUsed))[0] ?? null; +} + +function filterAndSortItems( + items: FavouriteItem[], + { + searchTerm, + selectedTypeId, + selectedSet, + viewMode, + sortMode, + }: { + searchTerm: string; + selectedTypeId: string; + selectedSet: FavouriteSet | null; + viewMode: ViewMode; + sortMode: SortMode; + }, +): FavouriteItem[] { + const normalizedSearch = searchTerm.trim().toLowerCase(); + const effectiveSort: SortMode = viewMode === "recent" ? "last-used" : sortMode; + + return items + .filter((item) => selectedTypeId === "all" || item.tabId === selectedTypeId) + .filter((item) => !selectedSet || item.set === selectedSet.title) + .filter((item) => { + if (viewMode === "source-backed") return isSourceBacked(item); + if (viewMode === "pinned") return item.pinned === true; + return true; + }) + .filter((item) => + normalizedSearch + ? [item.title, item.description, item.type, item.set, item.evidence].some((field) => + field.toLowerCase().includes(normalizedSearch), + ) + : true, + ) + .sort((first, second) => { + if (effectiveSort === "title") return first.title.localeCompare(second.title); + if (effectiveSort === "type") + return first.type.localeCompare(second.type) || first.title.localeCompare(second.title); + return lastUsedScore(second.lastUsed) - lastUsedScore(first.lastUsed); + }); +} + function MiniIconTile({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) { return ( & { - children: React.ReactNode; - active?: boolean; - className?: string; -}; - -function ToolbarButton({ children, active = false, className, ...props }: ToolbarButtonProps) { - return ( - - ); -} - -function SidebarSection({ - title, - action, - children, +function ActiveFilterChips({ + searchTerm, + selectedTypeId, + selectedSet, + viewMode, + onClearSearch, + onClearType, + onClearSet, + onClearViewMode, }: { - title: string; - action?: React.ReactNode; - children: React.ReactNode; + searchTerm: string; + selectedTypeId: string; + selectedSet: FavouriteSet | null; + viewMode: ViewMode; + onClearSearch: () => void; + onClearType: () => void; + onClearSet: () => void; + onClearViewMode: () => void; }) { - return ( -
-
-

{title}

- {action} -
- {children} -
- ); -} + const typeLabel = favouriteTabs.find((tab) => tab.id === selectedTypeId)?.label; + const chips: { key: string; label: string; onClear: () => void }[] = []; -function SidebarRow({ - icon: Icon, - label, - meta, - count, - active = false, -}: { - icon: LucideIcon; - label: string; - meta?: string; - count?: number; - active?: boolean; -}) { - return ( - - ); -} + if (searchTerm.trim()) chips.push({ key: "search", label: `Search: ${searchTerm.trim()}`, onClear: onClearSearch }); + if (selectedSet) chips.push({ key: "set", label: selectedSet.title, onClear: onClearSet }); + if (selectedTypeId !== "all" && typeLabel) chips.push({ key: "type", label: typeLabel, onClear: onClearType }); + if (viewMode === "source-backed") chips.push({ key: "view", label: "Source-backed", onClear: onClearViewMode }); + if (viewMode === "pinned") chips.push({ key: "view", label: "Pinned", onClear: onClearViewMode }); + if (viewMode === "recent") chips.push({ key: "view", label: "Recently used", onClear: onClearViewMode }); + + if (chips.length === 0) return null; -function FavouritesSidebar() { return ( - + {chip.label} + + Clear filter + + ))} +
); } -function ContinueStrip() { +function ContinueStrip({ item, onSelect }: { item: FavouriteItem; onSelect: (id: string) => void }) { + const Icon = item.icon; return ( -
-
+
+
-
-
- -
-
-

Continue

- -

Acamprosate renal screen

-
-

- Ward round · 3 sources · last opened Today 08:44 -

-
-
-
- - - Open - +
+
+
+ + + Continue +
); } -function FavouritesTable() { - const [selectedIds, setSelectedIds] = useState>(() => new Set([selectedItem.id])); - const [searchTerm, setSearchTerm] = useState(""); - const selectedCount = selectedIds.size; +function RowActionsMenu({ item }: { item: FavouriteItem }) { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + const menuRef = useRef(null); - const tableRows = useMemo(() => { - const normalizedSearch = searchTerm.trim().toLowerCase(); - const filteredItems = normalizedSearch - ? favouriteItems.filter((item) => - [item.title, item.description, item.type, item.set].some((field) => - field.toLowerCase().includes(normalizedSearch), - ), - ) - : favouriteItems; - return filteredItems.map((item) => ({ - ...item, - selected: selectedIds.has(item.id), - })); - }, [searchTerm, selectedIds]); - - function toggleRow(id: string) { - setSelectedIds((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - } + useDismissableLayer({ + enabled: open, + refs: [buttonRef, menuRef], + onDismiss: () => setOpen(false), + restoreFocusRef: buttonRef, + }); + + const actionLabel = item.action === "Copy" ? "Open" : item.action; return ( -
-
- - All favourites - 5 - - -
); } -function ItemWorkspace() { +function ItemWorkspace({ item, onClose }: { item: FavouriteItem; onClose: () => void }) { const [activeTab, setActiveTab] = useState<"summary" | "evidence" | "notes">("summary"); + const Icon = item.icon; + const actionLabel = item.action === "Copy" ? "Open" : item.action; return ( -

Saved in {" "} - Ward round + {item.set}

@@ -683,135 +746,105 @@ function ItemWorkspace() {
-
-

- Next action -

-

- Open for renal dose cautions, eGFR thresholds, and source links. -

- - Open item - - -

Last opened Today 08:44

-
- -
-
-

- Sources (3) -

- -
-
- {sourceRecords.map((source, index) => ( + {actionLabel} + + +

Last opened {item.lastUsed}

+
+ ) : null} + + {activeTab === "evidence" ? ( +
+

+ Sources (3) +

+
+ {sourceRecords.map((source, index) => ( +
+ + {index + 1} + + {source.title} + + {source.type} + +
+ ))} +
+
+ ) : null} + + {activeTab === "notes" ? ( +
+
+

+ Personal note +

- ))} -
- -
+
+
+

+ Useful for older patients with fluctuating eGFR. Check adherence section on page 4. +

+ Updated 11 May 2024 +
+ + ) : null} -
-
-

- Personal note -

+
+

More

+
+ -
-
-

- Useful for older patients with fluctuating eGFR. Check adherence section on page 4. -

-
- Updated 11 May 2024 - -
-
-
- -
-

- Actions -

-
- {[ - { label: "Ask a question", icon: MessageSquare }, - { label: "Copy citation", icon: Copy }, - { label: "Move to set", icon: Folder }, - ].map((action) => { - const Icon = action.icon; - return ( - - ); - })}
- +
+ ); +} + +export function FavouritesSidebar({ + collapsed, + onCollapsedChange, + ...navProps +}: FavouritesNavProps & { + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; +}) { + const sections = buildSidebarSections(navProps); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} + +function SetBrowseCard({ + set, + accentClass, + active = false, + onClick, +}: { + set: FavouritesNavSet; + accentClass: string; + active?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function FavouritesMobileBrowseRail({ + sets, + selectedSetId, + viewMode, + onSelectSet, + onSelectViewMode, +}: Pick) { + if (sets.length === 0) return null; + + return ( +
+

+ Browse sets +

+
+
+ {sets.map((set, index) => ( + { + onSelectViewMode("all"); + onSelectSet(selectedSetId === set.id ? null : set.id); + }} + /> + ))} +
+
+
+ ); +} diff --git a/src/components/clinical-dashboard/favourites-prototype-data.ts b/src/components/clinical-dashboard/favourites-prototype-data.ts index b6484c8bc..50d239aa6 100644 --- a/src/components/clinical-dashboard/favourites-prototype-data.ts +++ b/src/components/clinical-dashboard/favourites-prototype-data.ts @@ -1,4 +1,5 @@ -import { ClipboardList, FileText, Folder, LayoutList, Pill, Quote, Search, Stethoscope } from "lucide-react"; +import { ClipboardList, FileText, Folder, LayoutList, Pill, Quote, Search } from "lucide-react"; +import { appModeIcons } from "@/lib/app-mode-icons"; export type FavouriteType = "medications" | "documents" | "sources" | "services" | "forms" | "sets"; export type FavouriteTabId = "all" | FavouriteType; @@ -11,6 +12,7 @@ export type FavouriteItem = { meta: string; sourceMeta: string; primaryAction: string; + href: string; icon: typeof FileText; keywords: string; }; @@ -33,7 +35,7 @@ export const favouriteTabs: Array<{ { id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill }, { id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText }, { id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote }, - { id: "services", label: "Services", shortLabel: "Services", icon: Stethoscope }, + { id: "services", label: "Services", shortLabel: "Services", icon: appModeIcons.services }, { id: "forms", label: "Forms", shortLabel: "Forms", icon: ClipboardList }, { id: "sets", label: "Sets", shortLabel: "Sets", icon: Folder }, ]; @@ -47,6 +49,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "Medication page · renal cautions · dose notes", sourceMeta: "3 sources", primaryAction: "Open", + href: "/medications/acamprosate", icon: Pill, keywords: "acamprosate renal screen medication dose safety ward round pbs", }, @@ -58,6 +61,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "PDF · p.4-9 · 2 tables", sourceMeta: "PDF", primaryAction: "Ask", + href: "/?mode=documents&q=lithium+monitoring&run=1", icon: FileText, keywords: "lithium monitoring guideline blood tests shared care renal toxicity", }, @@ -69,6 +73,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "Saved table · ANC monitoring", sourceMeta: "Table", primaryAction: "Source", + href: "/?mode=documents&q=clozapine+monitoring+table&run=1", icon: Quote, keywords: "clozapine monitoring table anc fbc neutrophil clinic", }, @@ -80,6 +85,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "Saved query · medicines + documents", sourceMeta: "Run", primaryAction: "Run", + href: "/?mode=answer&q=renal+dose&run=1", icon: Search, keywords: "renal dose saved search kidney egfr medicines documents", }, @@ -91,6 +97,7 @@ export const favouriteItems: FavouriteItem[] = [ meta: "Source card · prescribing safety", sourceMeta: "Quote", primaryAction: "Copy", + href: "/?mode=documents&q=QT+prolongation&run=1", icon: Quote, keywords: "qt prolongation quote source card prescribing safety", }, diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 98edfc237..03c58ce4a 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -4,8 +4,10 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense, type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { ClinicalDashboard } from "@/components/clinical-dashboard"; +import { recentQueryStorageKey } from "@/components/ClinicalDashboard"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; -import { recentQueryStorageKey, SettingsDialog } from "@/components/ClinicalDashboard"; +import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; +import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { ClinicalDesktopSidebar, ClinicalMobileSidebar, @@ -16,6 +18,7 @@ import { MasterSearchHeader } from "@/components/clinical-dashboard/master-searc import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; +import { ClientHydrationBoundary } from "@/components/client-hydration-boundary"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref, @@ -24,6 +27,7 @@ import { visibleAppModeDefinitions, type AppModeId, } from "@/lib/app-modes"; +import { documentsSearchHref } from "@/lib/document-flow-routes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { useAuthSession } from "@/lib/supabase/client"; @@ -127,14 +131,21 @@ function GlobalMockupSearchShellClient({ const [settingsOpen, setSettingsOpen] = useState(false); const [accountSetupOpen, setAccountSetupOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); + const [commandScopes, setCommandScopes] = useState([]); const { theme, toggleTheme } = useTheme(); const auth = useAuthSession(); const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); const hasSubmittedModeSearch = requestedRun && requestedQuery.length > 0; - const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search"); + const isHomeRoute = pathname === "/"; + const isDocumentFlowRoute = pathname === "/documents/search" || pathname.startsWith("/documents/source"); + const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search") || isDocumentFlowRoute; + const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; + const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; const shouldRenderDashboardSearch = hasSubmittedModeSearch && resolvedSearchMode !== "services" && !isDocumentSearchMockupRoute; const isFormsOnlyShell = availableModeIds?.length === 1 && availableModeIds[0] === "forms"; + const shouldRenderFormsSearchResults = + shouldRenderDashboardSearch && resolvedSearchMode === "forms" && isFormsOnlyShell; const isStandaloneModeHome = !hasSubmittedModeSearch && !shouldRenderDashboardSearch && @@ -142,6 +153,8 @@ function GlobalMockupSearchShellClient({ (searchMode === "forms" && pathname === "/forms") || (searchMode === "favourites" && pathname === "/favourites") || (searchMode === "differentials" && pathname === "/differentials")); + /** Favourites needs library/results visible above the fold — skip hero composer there. */ + const useHeroModeHome = isStandaloneModeHome && searchMode !== "favourites"; const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); const shouldShowDesktopSidebar = !hideDesktopSidebar; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; @@ -204,7 +217,6 @@ function GlobalMockupSearchShellClient({ function openGuide() { setSettingsOpen(false); - setAccountSetupOpen(false); setMobileMenuOpen(false); setGuideOpen(true); } @@ -229,6 +241,10 @@ function GlobalMockupSearchShellClient({ } function navigateToMode(mode: AppModeId, options: { query?: string; run?: boolean; focus?: boolean } = {}) { + if (mode === "documents" && options.query?.trim()) { + router.push(documentsSearchHref(options)); + return; + } router.push(appModeHomeHref(mode, options)); } @@ -243,40 +259,42 @@ function GlobalMockupSearchShellClient({ function changeMode(mode: AppModeId) { setQuery(""); + setCommandScopes([]); setSearchMode(mode); setMobileMenuOpen(false); navigateToMode(mode); } - function startNewChat() { - setQuery(""); - setSearchMode(fallbackMode); - setMobileMenuOpen(false); - navigateToMode(fallbackMode, { focus: true }); - } - function startNewAnswerChat() { setQuery(""); setMobileMenuOpen(false); - navigateToMode("answer", { focus: true }); + // On standalone mode routes (favourites, services, etc.) keep the user in + // that workspace and only clear the query — same as sidebar "New chat". + navigateToMode(searchMode === "answer" ? "answer" : searchMode, { focus: true }); } function pickRecentQuery(recentQuery: string) { setMobileMenuOpen(false); - navigateToMode("answer", { query: recentQuery, focus: true }); + navigateToMode(searchMode, { query: recentQuery, focus: true, run: true }); } - if (shouldRenderDashboardSearch && resolvedSearchMode === "forms" && isFormsOnlyShell) { - return ; + function crossModeSearch(mode: AppModeId, crossQuery: string) { + setQuery(crossQuery); + setCommandScopes([]); + setSearchMode(mode); + setMobileMenuOpen(false); + navigateToMode(mode, { query: crossQuery, focus: true, run: true }); } - if (shouldRenderDashboardSearch) { + const shouldRenderClinicalDashboard = isHomeRoute || (shouldRenderDashboardSearch && !shouldRenderFormsSearchResults); + + if (shouldRenderClinicalDashboard) { return ( ); } @@ -295,18 +313,21 @@ function GlobalMockupSearchShellClient({
{shouldShowDesktopSidebar ? ( -
+
-
+ {/* max-sm:contents lets the header's own `sticky top-0` engage against + the document scroll on phones (a plain wrapper div otherwise caps + its sticking range at its own height), which the phone + hide-on-scroll overlay relies on. */} +
setQuery("")} + onClearQuery={() => { + setQuery(""); + if (isStandaloneModeHome) navigateToMode(searchMode, { focus: true }); + }} onClearScope={() => undefined} onQueryModeChange={setQueryMode} onScopeFiltersChange={setScopeFilters} onToggleScope={() => undefined} onOpenUpload={() => router.push(`${appModeHomeHref("documents", { focus: true })}#sources`)} onOpenEvidence={() => navigateToMode("answer", { focus: true })} - onNewChat={startNewChat} + onNewChat={startNewAnswerChat} onOpenMobileSidebar={() => setMobileMenuOpen(true)} mobileLeadingAction={ pathname === "/differentials" && searchMode === "differentials" && requestedQuery ? "back" : "menu" @@ -361,14 +389,28 @@ function GlobalMockupSearchShellClient({ }} queryModeOptions={mockupQueryModeOptions} queryInputRef={inputRef} + recentQueries={recentQueries} + commandScopes={commandScopes} + onCommandScopesChange={setCommandScopes} + onPickRecent={pickRecentQuery} + onCrossModeSearch={crossModeSearch} headerVariant={isDifferentialPresentationWorkflow ? "workflow" : "default"} mobileSearchPlacement="bottom" + // Submitted searches that stay in the shell (services results) are + // result views: compact the phone bottom composer so results keep + // maximum screen space. Mode homes keep the chip-row layout. + mobileBottomSearchVariant={ + useCompactBottomSearch || (isStandaloneModeHome && searchMode === "favourites") ? "compact" : "default" + } desktopSearchPlacement={ - (desktopSearchPlacement === "hero" || isFormsOnlyShell) && isStandaloneModeHome ? "hero" : "default" + (desktopSearchPlacement === "hero" || isFormsOnlyShell) && useHeroModeHome ? "hero" : "default" } searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} - heroComposerFromTablet={isStandaloneModeHome} + heroComposerFromTablet={useHeroModeHome} + // Phone-only: the document scrolls here and the header is sticky, + // so a translate overlay hides it with zero layout shift. + hideOnScroll={{ strategy: "overlay" }} />
@@ -376,15 +418,34 @@ function GlobalMockupSearchShellClient({ id="main-content" tabIndex={-1} className={cn( - "min-h-[calc(100dvh-4rem)] min-w-0 overflow-x-hidden focus:outline-none", + // Phone: fill the space under the header exactly (the header is + // taller than the 4rem the calc assumed, which forced a phantom + // scrollbar on every standalone page). sm+ keeps the original calc. + "min-w-0 overflow-x-hidden focus:outline-none max-sm:flex-1 sm:min-h-[calc(100dvh-4rem)]", !shouldShowSearchComposer ? "pb-8" : searchMode === "answer" ? "pb-[calc(9rem+env(safe-area-inset-bottom))]" - : "pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", + : useCompactBottomSearch + ? "pb-[calc(5.5rem+env(safe-area-inset-bottom))] sm:pb-8" + : "pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", )} > - {children} + } + > + setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), + onClearScopes: () => setCommandScopes([]), + }} + > + {shouldRenderFormsSearchResults ? : children} + +
@@ -401,6 +462,9 @@ function GlobalMockupSearchShellClient({ setAccountSetupOpen(false)} /> = { - answer: Sparkles, - documents: FileText, - services: ShieldCheck, - forms: FileSignature, - favourites: Heart, - differentials: BrainCircuit, - prescribing: Pill, - tools: Wrench, -}; const medicationModeActionItems: readonly ModeActionItem[] = [ { @@ -200,14 +189,23 @@ export function MasterSearchHeader({ queryModeOptions, queryInputRef, queryInputAutoFocus = false, + composerPlaceholder, + recentQueries = [], + commandScopes = [], + onCommandScopesChange, + onPickRecent, + onCrossModeSearch, headerVariant = "default", mobileSearchPlacement = "default", + mobileBottomSearchVariant = "default", desktopSearchPlacement = "default", searchComposerVisible = true, desktopHomeComposerSlotId, + mobileBottomSearchAddonSlotId, heroComposerFromTablet = false, mobileLeadingAction = "menu", onMobileBack, + hideOnScroll, }: { documents: ClinicalDocument[]; documentTotal?: number; @@ -237,16 +235,35 @@ export function MasterSearchHeader({ queryModeOptions: Array<{ value: ClinicalQueryMode; label: string }>; queryInputRef?: Ref; queryInputAutoFocus?: boolean; + /** Overrides the mode's default input placeholder (e.g. "Ask a follow-up..." mid-thread). */ + composerPlaceholder?: string; + recentQueries?: string[]; + commandScopes?: string[]; + onCommandScopesChange?: (scopes: string[]) => void; + onPickRecent?: (query: string) => void; + onCrossModeSearch?: (modeId: AppModeId, query: string) => void; headerVariant?: "default" | "workflow"; mobileSearchPlacement?: "default" | "bottom"; + /** "compact" drops the phone footer chip row and hugs the bottom edge — + * used by search/result views so results keep maximum screen space. + * Mode homes keep the default chip-row layout. */ + mobileBottomSearchVariant?: "default" | "compact"; desktopSearchPlacement?: "default" | "hero"; searchComposerVisible?: boolean; desktopHomeComposerSlotId?: string; + /** Phone-only slot rendered above the bottom search pill for page-specific dock addons. */ + mobileBottomSearchAddonSlotId?: string; /** Portal the composer into the hero slot from the tablet breakpoint (sm) up, * rather than the default desktop (lg) breakpoint. */ heroComposerFromTablet?: boolean; mobileLeadingAction?: "menu" | "back"; onMobileBack?: () => void; + /** Phone-only hide-on-scroll for the universal header. "overlay" translates + * the sticky header away (host scrolls the document, content already flows + * beneath); "collapse" also releases the header's layout space (host keeps + * the header above an internally scrolling element). `containerRef` points + * at the scrolling element; omit it to observe window scroll. */ + hideOnScroll?: { strategy: "overlay" | "collapse"; containerRef?: RefObject }; }) { const visibleAppModeOptions = defaultVisibleAppModeOptions; const trimmedQuery = query.trim(); @@ -259,6 +276,7 @@ export function MasterSearchHeader({ const isMobileBottomComposer = searchComposerVisible && mobileSearchPlacement === "bottom" && !isAnswerFooterComposer; const isHeroDesktopComposer = desktopSearchPlacement === "hero" && isMobileBottomComposer; const canRunLocalSearch = + selectedSearch.kind === "documents" || searchMode === "forms" || selectedSearch.kind === "services" || selectedSearch.kind === "tools" || @@ -275,10 +293,22 @@ export function MasterSearchHeader({ const [scopeSheetFullscreen, setScopeSheetFullscreen] = useState(false); const [actionMenuOpen, setActionMenuOpen] = useState(false); const [actionMenuPlacement, setActionMenuPlacement] = useState("up"); + const [commandDropdownOpen, setCommandDropdownOpen] = useState(false); + const [commandListboxId, setCommandListboxId] = useState(); const [modeMenuOpen, setModeMenuOpen] = useState(false); const [usesScopeSheet, setUsesScopeSheet] = useState(false); const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false); const [desktopHomeComposerActive, setDesktopHomeComposerActive] = useState(false); + // Phone-only hide-on-scroll: never hide while a header-owned surface is open + // or while focus sits inside the header chrome (keyboard users must not tab + // into invisible controls). + const [headerChromeFocused, setHeaderChromeFocused] = useState(false); + const scrollHidden = useHideOnScroll({ + containerRef: hideOnScroll?.containerRef, + disabled: !hideOnScroll, + }); + const headerChromeHidden = + scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; // Stable, header-owned element the composer is portaled into; we move it in and // out of the page-owned slot rather than portaling into the slot directly. const [desktopHomeComposerHost, setDesktopHomeComposerHost] = useState(null); @@ -357,7 +387,8 @@ export function MasterSearchHeader({ const activeQuickFilterCount = (scopeFilters.sourceStatuses?.length ? 1 : 0) + (scopeFilters.locality ? 1 : 0) + activeLabelFilterCount; const submitLabel = trimmedQuery ? selectedSearch.submitBusyLabel : selectedSearch.submitIdleLabel; - const queryPlaceholder = isAnswerFooterComposer ? "Ask Clinical Guide" : selectedSearch.placeholder; + const queryPlaceholder = + composerPlaceholder ?? (isAnswerFooterComposer ? "Ask Clinical Guide" : selectedSearch.placeholder); const SelectedAppModeIcon = appModeIcons[selectedAppMode.id]; const actionMenuModeOptions = useMemo( () => @@ -1127,9 +1158,13 @@ export function MasterSearchHeader({ const isDesktopHomeComposer = placement === "desktop-home"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer; + const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer; - const showFooterSearchChips = usesFooterChipLayout; + // Compact search views drop the chip row on phones so the pill can sit + // flush with the bottom edge; the same actions stay reachable via the + // integrated "+" menu. + const showFooterSearchChips = usesFooterChipLayout && !usesCompactMobileBottomStyle; // The visible footer/hero composer chrome is universal; submit semantics still // come from the active mode. const usesSendAffordance = searchMode === "answer" || usesFooterChipLayout; @@ -1146,9 +1181,16 @@ export function MasterSearchHeader({ const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; + const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; + + const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; + return (
{usesBottomComposerPlacement ?