diff --git a/.env.example b/.env.example index 4a1c23597b..ea0054bf66 100644 --- a/.env.example +++ b/.env.example @@ -23,19 +23,21 @@ 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 -OPENAI_ANSWER_MODEL=gpt-5.4-mini -OPENAI_FAST_ANSWER_MODEL=gpt-5.4-mini -OPENAI_STRONG_ANSWER_MODEL=gpt-5.4 -OPENAI_MAX_OUTPUT_TOKENS=1400 +OPENAI_ANSWER_MODEL=gpt-5.5 +OPENAI_FAST_ANSWER_MODEL=gpt-5.5 +OPENAI_STRONG_ANSWER_MODEL=gpt-5.5-pro +OPENAI_MAX_OUTPUT_TOKENS=4000 OPENAI_QUERY_CACHE_SIZE=200 -OPENAI_VISION_MODEL=gpt-5.4-mini +OPENAI_VISION_MODEL=gpt-5.5 +OPENAI_VISION_IMAGE_DETAIL=auto OPENAI_REQUEST_TIMEOUT_MS=45000 OPENAI_MAX_RETRIES=2 -OPENAI_PROMPT_CACHE_RETENTION=in_memory +OPENAI_GENERATION_MAX_RETRIES=0 +OPENAI_PROMPT_CACHE_RETENTION=24h OPENAI_STORE_RESPONSES=false OPENAI_FAST_REASONING_EFFORT=low -OPENAI_STRONG_REASONING_EFFORT=medium -OPENAI_SUMMARY_REASONING_EFFORT=low +OPENAI_STRONG_REASONING_EFFORT=high +OPENAI_SUMMARY_REASONING_EFFORT=medium OPENAI_VISION_REASONING_EFFORT=low OPENAI_TEXT_VERBOSITY=low RAG_ANSWER_CACHE_TTL_MS=300000 @@ -65,6 +67,7 @@ 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_VISION_CONCURRENCY=4 WORKER_INLINE_ENRICHMENT=false PYTHON_BIN=python # Optional when Tesseract is installed outside PATH on Windows. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21aacb0b77..380965d49c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,8 @@ jobs: - name: Typecheck run: npm run typecheck - - name: Unit tests - run: npm run test + - name: Unit tests (with coverage gate) + run: npm run test:coverage - name: Build run: npm run build diff --git a/TOOLS_CONTEXT_FOR_NEW_CHAT.md b/TOOLS_CONTEXT_FOR_NEW_CHAT.md new file mode 100644 index 0000000000..6855ec1105 --- /dev/null +++ b/TOOLS_CONTEXT_FOR_NEW_CHAT.md @@ -0,0 +1,60 @@ +# Context handoff: Tools / Applications UX task + +## Purpose +I’m creating this file so a new chat can continue the exact task with full context, including screenshots referenced so far. + +## Scope of this side conversation +- This side conversation is separate from the main thread after a side boundary. +- Pre-boundary history is reference-only and should not be treated as active instructions unless explicitly restated. +- Active request in this side conversation was focused on design iteration and mockup direction for the Tools experience. +- User now requested: create a context file with this chat + main run chat context including all images. + +## Current repository/workspace +- Working directory: `C:\Dev\Apps\Database` +- No explicit code mutation request was made in this side chat beyond creating this handoff file. + +## Latest user intent (current side thread) +1. User asked to remove unimportant fields from the selected tool detail UI (examples: scope, source type, last use, etc.). +2. User requested three new mockups in a better, more compact, polished style with stronger UX/UI treatment. +3. User emphasized optimizing for popup/detail panel UX and improving design quality/compactness. + +## Key visual/UX direction already established +- Remove low-value fields from selected-tool details. +- Keep focused content: identity/status, launch action, concise overview/description, primary actions, minimal context. +- Preserve mobile-first detail presentation quality (slide-up/detail sheet style). +- Keep tools functionality, improve layout density and hierarchy. +- Keep design intentional and “best UX” style rather than boilerplate. + +## What was done in this side thread (summary) +- Read local frontend design skill file (non-mutating exploration only). +- Generated three mockup designs: + 1. Compact action panel + 2. Bottom command sheet + 3. Row-attached mini inspector +- Each mockup was framed as desktop + iPhone in one image. +- Shared recommendations to prefer concise, practical selected-tool detail content and keep popup behavior robust. + +## Files/attachments referenced by user (screenshots) +- `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-79c36fc1-c28a-435c-bb2d-6988f78f89e1.png` +- `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-0c947a77-a138-4762-8152-441be907d12b.png` +- `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-8060b302-8f9b-4ae4-8f51-a3e7bfb51bac.png` +- `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-c496ebea-7c2c-4e27-a1e4-693bb494bdc9.png` +- `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-8b3b0d40-2e87-4870-abc8-cf5ecad3d94b.png` +- `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-0b05c4e9-1715-47c2-bd70-ed93133384ae.png` + +## Inherited “main run chat” context (pre-boundary) +- Earlier thread work focused on Tools/Applications navigation and behavior in the app. +- User wanted the standalone `/applications` page preserved and restored. +- User then asked to embed Applications launcher content into `/?mode=tools` while renaming visible copy to Tools. +- The user requested sidebar/floating action updates to treat the surface as Tools, and icon/title updates. +- They repeatedly reported selected tool desktop popup behavior needs fixing and asked for a design upgrade. +- The side-thread task proceeded from this context but now is narrowed to design direction + new mockups. + +## Notes for continuation in new chat +- This file is only a handoff context snapshot; it does not include any uncommitted design/code changes in this side thread. +- If continuing implementation, likely first action is reconciling existing Tools detail component with the above compact popup/content model. +- Use the images above to ground visual expectations. +- Keep `/applications` and `/?mode=tools` behavior requirements from the parent context in mind. + +## Suggested prompt for new chat +“Please continue from this context file. We need to implement the improved tools selected-item popup/detail view to remove low-value metadata (scope/source type/last use), keep core actions and context, and apply one of the three compact mockup directions (or a refinement). Preserve standalone `/applications` behavior and ensure `/ ?mode=tools` still works in desktop + mobile.” diff --git a/docs/clinical-badge-system-guide.md b/docs/clinical-badge-system-guide.md new file mode 100644 index 0000000000..947f7f564b --- /dev/null +++ b/docs/clinical-badge-system-guide.md @@ -0,0 +1,502 @@ +# Clinical Badge System Guide + +This guide defines the badge, chip, and compact label system for Clinical KB. Use it as context for future UI, medication, source, document, evidence, answer, and labelling tasks. + +This document is governance only. It does not grant approval to apply badges across clinical content. Apply this system to a content area only when that area is explicitly approved for redesign or labelling work. + +## Core Principles + +Badges should make clinical screens faster to scan. They are not decoration, not a substitute for readable clinical text, and not a way to make every fact look important. + +Default to no badge. Add a badge only when removing it would make the screen less scannable, less safe, or less clear. + +Badges should answer one of these questions: + +| Question | Badge role | +| --- | --- | +| What is this? | Metadata or type | +| What should I do? | Clinical action | +| Can I trust or use this? | Trust, currentness, availability, or source support | +| Should I pause and check? | Caution, adjustment, uncertainty, or review needed | +| Should I stop or avoid? | Contraindication, failure, unsafe state | +| Is the system doing something? | Process or system state | + +## Badge Versus Label Versus Chip + +Use these terms consistently. + +| Term | Meaning | Interaction | +| --- | --- | --- | +| Badge | Compact, static visual marker for state or metadata | Not clickable | +| Chip | Interactive filter, selected token, query token, or mode selector | Clickable/removable/selectable | +| Label | Data classification attached to content, documents, concepts, or rows | May be rendered as text or badge, but is not automatically a UI badge | +| Tag | User-facing classification label, especially for document/manual tagging | May be interactive when used for search/filtering | +| Status | Operational state such as current, processing, failed, reviewed | Usually rendered as a badge | + +Static badges must not look clickable. Interactive chips must use proper button or link semantics. + +## Master Palette + +Use six top-level tones only. Do not add more badge colours. + +| Tone | Meaning | Examples | Do not use for | +| --- | --- | --- | --- | +| Neutral / slate | Reference metadata and passive facts | `333 mg EC tablet`, `Item 8357W`, `Campral`, `p.4`, `PDF`, `Max 1,998 mg/day` | Urgent risks, actions, or trust state | +| Clinical / teal | Action to take | `666 mg TID`, `Monitor renal`, `Take with food`, `Check baseline` | Verified/current/source-backed state | +| Success / green | Confirmed, current, available, source-backed | `Reviewed`, `Current`, `Source-backed`, `PBS streamlined`, `Completed` | Clinical safety decisions | +| Warning / amber | Pause, check, adjust, uncertain, limited | `Reduce <60 kg`, `Review due`, `Partial support`, `Limited evidence`, `Avoid >65` | Hard stops or contraindications | +| Danger / red | Stop, avoid, failed, unsafe | `Cr >120 avoid`, `Contraindicated`, `Outdated`, `Failed`, `Do not use` | Routine adverse effects or mild cautions | +| Info / blue | System or process information | `Processing`, `Syncing`, `Pending`, `Importing` | Core clinical meaning | + +Do not add purple, pink, orange, cyan, or extra medication-specific badge colours. Orange collapses into amber. Purple should not be used for clinical badges because it reads as product or AI styling rather than clinical meaning. + +## Tone Rules + +Green means trusted, current, reviewed, available, or source-backed. It does not mean clinically safe. + +Teal means a clinical action or instruction. It does not mean good, safe, or verified. + +Amber means pause, check, adjust, review, or interpret with caution. + +Red means stop, avoid, contraindicated, failed, outdated, or unsafe. + +Blue is reserved for system and process state. It should be rare in clinical content. + +Neutral is the default for facts that do not require action, caution, trust signalling, or alarm. + +## Visual Variants + +Use variants within the six tones instead of adding new colours. + +| Variant | Use | Visual weight | +| --- | --- | --- | +| Quiet | Default static badge | Lowest | +| Standard | Normal clinical/status badge | Low-medium | +| Strong | Major warning, hard stop, selected interactive chip | High, rare | +| Count | `4 matches`, `12 sources`, `+2` | Compact | +| Dot + label | Very dense status rows | Minimal | + +Default badge styling should be quiet. + +## Shape And Size + +Badges should be compact and stable. + +Recommended static badge shape: + +- `inline-flex` +- `rounded-md` +- subtle `border` +- low-contrast background tint +- `10-11px` text +- compact horizontal padding +- one line only +- optional icon only when it improves recognition + +Avoid: + +- oversized pill badges +- heavy shadows +- hover lift on static badges +- button-like padding +- full sentence labels +- wrapping badges across several lines +- icon on every badge + +## Interaction Rules + +Static badges: + +- no pointer cursor +- no hover lift +- no active state +- no button role +- no click handler +- no keyboard focus + +Interactive chips: + +- use `button`, `a`, or the correct interactive primitive +- have hover and focus-visible states +- have accessible names +- show selected/removable state clearly +- may use larger padding than static badges + +Do not use the same component for static badges and interactive chips unless it has explicit modes and safe defaults. + +## Density Rules + +Badges are easy to overuse. Apply hard limits. + +| Context | Limit | +| --- | --- | +| Desktop detail row | 3 visible badges | +| Mobile detail row | 2 visible badges | +| Search result row/card | 2-3 visible badges | +| Top summary tile | 0 badges | +| Hard-stop safety row | Up to 4 red badges if each is a separate contraindication | +| Source/evidence row | 2-3 badges | +| Document tag cloud | Use tag limits and show-more behaviour | + +When there are too many badges, show the highest-priority badges first and move the rest into expanded detail or a quiet count such as `+2`. + +Priority order: + +1. Danger +2. Warning +3. Clinical +4. Success +5. Neutral +6. Info + +## Placement Rules + +Do not put badges in top medication summary tiles. Those should use icon, label, value, and muted subline only. + +Use badges inside: + +- medication detail rows +- medication search result metadata +- source and evidence state +- document search result metadata +- answer grounding state +- admin/import/indexing status +- compact count areas + +Avoid badges inside: + +- prose paragraphs +- every bullet +- headings already visible nearby +- primary navigation +- buttons that already have clear labels +- hero/home prompt areas unless representing a selected token or filter chip + +## Badge Text Rules + +Badge labels must be short, clinical, and scannable. + +Good labels: + +- `666 mg TID` +- `Max 1,998 mg/day` +- `Reduce <60 kg` +- `Cr >120 avoid` +- `Reviewed` +- `PBS streamlined` +- `Current` +- `Source-backed` +- `Review due` +- `Partial support` + +Poor labels: + +- `This medication should not be used in patients with renal impairment` +- `The document appears to be source backed by the uploaded guideline` +- `You should check renal function before prescribing this medication` +- `This is probably relevant` + +Prefer nouns and concise clinical phrases. Use full prose in the row body, not in the badge. + +## Clinical Safety Rules + +Critical clinical information must not be badge-only. If a contraindication is shown as a red badge, it must also appear in readable text. + +Badges highlight. They do not replace: + +- prescribing answer text +- contraindication detail +- dose adjustment instructions +- monitoring requirements +- source provenance +- patient-specific context + +Use red only for true stop or failure states. Routine side effects are not red unless they imply stop/avoid/escalation. + +## Medication Rules + +Medication pages may use badges only where they improve scanning. + +| Medication content | Tone | +| --- | --- | +| Formulation | Neutral | +| Brand | Neutral | +| PBS item | Neutral | +| PBS availability/streamlined | Success | +| Reviewed/source-backed medication state | Success | +| Usual dose as an instruction | Clinical | +| Administration instruction | Clinical | +| Monitoring action | Clinical | +| Dose ceiling/reference max dose | Neutral | +| Dose adjustment | Warning | +| Renal/hepatic caution | Warning | +| Population not established | Warning | +| Contraindication/do not use | Danger | +| Routine adverse effect | Neutral | +| High/common adverse effect needing attention | Warning | +| Serious adverse effect/stop state | Danger | + +Acamprosate examples: + +| Label | Tone | Reason | +| --- | --- | --- | +| `333 mg EC tablet` | Neutral | Formulation | +| `PBS streamlined` | Success | Access/status | +| `Reviewed` | Success | Trust/status | +| `666 mg TID` | Clinical | Dosing instruction | +| `2 x 333 mg` | Neutral | Dose detail | +| `Max 1,998 mg/day` | Neutral | Reference ceiling | +| `Reduce <60 kg` | Warning | Dose adjustment | +| `Take with food` | Clinical | Administration instruction | +| `Do not crush` | Warning | Administration caution | +| `Cr >120 avoid` | Danger | Contraindication | +| `Child-Pugh C` | Danger | Contraindication | + +## Search Result Rules + +Medication and document search results should stay search-focused. + +Use at most: + +- one match/relevance badge +- one clinical metadata badge +- one caution/action badge if needed + +Do not show a large badge cluster in a result row. If every result has many badges, the page becomes a detail page instead of search results. + +Search match examples: + +| Label | Tone | +| --- | --- | +| `Exact match` | Success | +| `Source-backed` | Success | +| `Partial support` | Warning | +| `Nearby only` | Warning | +| `No direct support` | Warning or danger depending on context | +| `Dose match` | Clinical or success depending on whether it is an action or evidence state | + +## Answer And Evidence Rules + +Answer badges should clarify grounding and evidence strength. + +| Evidence state | Tone | +| --- | --- | +| Direct source-backed support | Success | +| Strong source | Success | +| Partial support | Warning | +| Nearby only | Warning | +| No direct support where direct support is required | Danger | +| Source current | Success | +| Source review due | Warning | +| Source outdated | Danger | +| Page/source metadata | Neutral | + +Do not use badges to decorate answer prose. Use them at the answer header, source rows, evidence panels, and compact provenance areas. + +## Source And Document Rules + +Document labels and document badges are related but not the same. + +Document labels classify the document. UI badges render only selected labels or states that help the user scan. + +| Source/document item | Tone | +| --- | --- | +| Site/organisation metadata | Neutral or info if operationally helpful | +| Document type | Neutral | +| Manual override | Info | +| Needs review | Warning | +| Ambiguous site | Warning | +| Current source | Success | +| Review due | Warning | +| Outdated source | Danger | +| Processing/indexing | Info | +| Failed ingestion | Danger | +| Indexed/completed | Success | + +Limit visible tags. Use show-more behaviour for document tag clouds. + +## Admin And Ingestion Rules + +Operational status badges should be simple and consistent. + +| State | Tone | +| --- | --- | +| Queued | Neutral | +| Processing | Info | +| Completed/indexed | Success | +| Needs review | Warning | +| Low confidence | Warning or info depending on severity | +| Duplicate/noisy | Warning | +| Failed | Danger | + +Do not make operational process badges look like clinical safety badges. Keep copy explicit. + +## Labelling Task Guide + +When performing a labelling task, decide whether each item is: + +1. A data label. +2. A UI badge. +3. A search/filter chip. +4. Plain text. +5. Not needed. + +Use this decision path: + +1. Is the label needed for retrieval, grouping, or data governance? + - If yes, create or update a data label. +2. Is the label needed for fast visual scanning in the UI? + - If yes, render a badge or chip. +3. Is it interactive? + - If yes, it is a chip/filter/tag button, not a static badge. +4. Is it critical clinical information? + - If yes, include readable text first; badge may supplement it. +5. Is it only decorative or repeating nearby text? + - If yes, do not badge it. + +Labelling tasks must not infer permission to redesign every content area. Apply badge rendering only to explicitly approved screens or content types. + +## Approval Scope Rules + +Allowed without additional approval: + +- Create or update this guide. +- Propose badge mappings. +- Apply badge improvements to a content area the user explicitly names and approves. +- Refactor shared badge primitives if the user asks to implement the guide. + +Not allowed without explicit approval: + +- Broadly relabel all clinical content. +- Apply badge mappings to all existing medication/source/answer content. +- Change clinical facts or source meaning. +- Promote generated labels into clinical decisions. +- Add new badge colours. +- Make static badges interactive. + +## Component Guidance + +The app should move toward two shared primitives: + +1. `SemanticBadge` + - static + - semantic tone + - quiet/standard/strong/count/dot variants + - safe truncation + - optional icon + +2. `InteractiveChip` + - interactive + - selected/removable/filter modes + - focus-visible state + - keyboard accessible + +Recommended semantic tone names: + +```ts +type BadgeTone = "neutral" | "clinical" | "success" | "warning" | "danger" | "info"; +type BadgeVariant = "quiet" | "standard" | "strong" | "count" | "dot"; +``` + +Avoid code-facing colour names such as `green`, `red`, `amber`, or `slate` for new badge APIs. Use semantic names so future contributors select tone by meaning, not hue. + +Map semantic tones to existing tokens: + +| Semantic tone | Existing style direction | +| --- | --- | +| `neutral` | `toneNeutral` / metadata pill | +| `clinical` | clinical teal token | +| `success` | `toneSuccess` | +| `warning` | `toneWarning` or `toneWarningQuiet` | +| `danger` | `toneDanger` | +| `info` | `toneInfo` | + +## Accessibility Requirements + +Badges must remain understandable without colour. + +Requirements: + +- clear text label +- colour plus text, not colour alone +- icon only when useful +- high enough contrast in light and dark mode +- forced-colors support +- no clipped critical labels +- no badge-only clinical risks +- static badges should not receive keyboard focus +- interactive chips must be keyboard accessible + +## Content Quality Checklist + +Before adding a badge, ask: + +- Is this fact important enough to scan visually? +- Is the same text already obvious nearby? +- Is the tone based on meaning, not visual preference? +- Would this badge still make sense without colour? +- Can it fit on a phone? +- Is the label under 18-22 characters where possible? +- Does the row stay readable if the badge is removed? +- Is critical clinical content also present in prose? +- Is this badge static, or should it be an interactive chip? +- Has this content area been approved for badge implementation? + +## Visual QA Checklist + +Check every badge implementation at: + +- narrow phone width +- desktop width +- dark mode +- forced-colors mode +- long labels +- empty/missing values +- high badge counts +- row hover/focus states +- keyboard navigation for chips +- screen-reader names for interactive chips + +Failures to fix: + +- text clipped in a clinically unsafe way +- badge wraps into awkward multi-line clusters +- static badge looks clickable +- row height increases because of low-priority badges +- red/amber overused +- green implies safety +- blue used for clinical meaning +- content depends on colour alone + +## Anti-Patterns + +Avoid: + +- badge every medication fact +- badge every answer bullet +- use green for "safe" +- use red for routine side effects +- use teal for verified/current +- use blue for clinical action +- add purple for AI +- add orange beside amber +- put badges in top decision tiles +- use badges as buttons +- use full sentences in badges +- show five or more badges in a mobile row +- let badge clusters turn search results into detail pages + +## Quick Reference + +Use this mapping by default: + +| If the label means... | Use tone | +| --- | --- | +| Passive fact | Neutral | +| Clinical instruction | Clinical | +| Current/reviewed/source-backed/available | Success | +| Adjustment/caution/review/partial | Warning | +| Avoid/contraindicated/failed/unsafe | Danger | +| Processing/pending/system | Info | + +Use fewer badges than feels tempting. The goal is clinical scanning, not visual decoration. diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 448874a2f0..927d663c94 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -9,7 +9,7 @@ This document turns the current process review into phased, durable repo practic - `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. - `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` launcher at mobile and desktop sizes. +- `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. ## Phase 2 - Active now @@ -17,7 +17,7 @@ This document turns the current process review into phased, durable repo practic - Previous deterministic smoke failures are reclassified as resolved in the current Chromium UI gate: `npm run verify:ui` passed 26/26 on June 23, 2026. - 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` now has dedicated Playwright coverage in the UI gate. +- Applications mode now has dedicated Playwright coverage in the UI gate. ## Phase 3 - Structural cleanup diff --git a/scripts/classify-documents.ts b/scripts/classify-documents.ts index 1340885e97..7498d2eb4e 100644 --- a/scripts/classify-documents.ts +++ b/scripts/classify-documents.ts @@ -110,7 +110,7 @@ async function loadDocuments(supabase: SupabaseAdmin, args: ClassifyArgs) { .from("documents") .select("id,owner_id,title,file_name,source_path,status,metadata") .eq("status", "indexed") - .order("created_at", { ascending: true }) + .order("id", { ascending: true }) .range(args.documentId ? 0 : args.offset, args.documentId ? 0 : args.offset + args.limit - 1); if (args.documentId) query = query.eq("id", args.documentId); @@ -165,7 +165,7 @@ async function writeClassification( .delete() .eq("document_id", document.id) .eq("source", "generated") - .in("label_type", ["site", "document_type", "population", "topic", "setting", "service", "workflow"]); + .in("label_type", ["site", "document_type", "population", "topic", "setting", "service", "workflow", "medication"]); if (deleteError) throw new Error(deleteError.message); // Write site labels (confident only, >= 0.75) @@ -176,10 +176,10 @@ async function writeClassification( (label) => label.label_type === "document_type" && label.confidence >= 0.5, ); - // Write all secondary facet labels (population, topic, setting, service, workflow) + // Write all secondary facet labels (population, topic, setting, service, workflow, medication) const secondaryLabels = classification.labels.filter( (label) => - ["population", "topic", "setting", "service", "workflow"].includes(label.label_type) && label.confidence >= 0.5, + ["population", "topic", "setting", "service", "workflow", "medication"].includes(label.label_type) && label.confidence >= 0.5, ); const generatedLabels = [...siteLabels, ...typeLabels, ...secondaryLabels]; diff --git a/scripts/production-readiness.ts b/scripts/production-readiness.ts index 1030fb4bd1..dd80bce994 100644 --- a/scripts/production-readiness.ts +++ b/scripts/production-readiness.ts @@ -81,6 +81,15 @@ function recordNoAuthProductionCheck() { } } +function recordDemoModeProductionCheck() { + if ( + (process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production") && + process.env.NEXT_PUBLIC_DEMO_MODE === "true" + ) { + result.failures.push("Demo mode (NEXT_PUBLIC_DEMO_MODE=true) is enabled in a production-like environment."); + } +} + async function checkFileForServiceRoleExposure() { const envFiles = [".env", ".env.local", ".env.production", ".env.development"]; for (const fileName of envFiles) { @@ -103,6 +112,7 @@ async function checkFileForServiceRoleExposure() { async function main() { checkNodeRuntime(); recordNoAuthProductionCheck(); + recordDemoModeProductionCheck(); await checkFileForServiceRoleExposure(); if (!(await checkRequiredFile(path.join(process.cwd(), "package-lock.json"), "package-lock.json is required"))) { diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 7c7c2935a0..c1eb1bec37 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -91,12 +91,15 @@ export async function POST(request: Request) { ownerId: user.id, queryMode: body.queryMode, skipCache: body.skipCache, + signal: request.signal, }); const warnings = sourceGovernanceWarnings({ results: answer.sources ?? [], relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null, }); - if (hasDangerSourceGovernanceWarning(warnings)) { + const shouldUseSourceGovernanceRefusal = + answer.grounded !== false && answer.confidence !== "unsupported" && answer.responseMode !== "evidence_gap"; + if (shouldUseSourceGovernanceRefusal && hasDangerSourceGovernanceWarning(warnings)) { // Build the refusal explicitly — never spread ...answer here, or the original // (refused) sources/smartPanel/smartApiPlan would still reach the client and // defeat the refusal. Keep only the safe "unsupported" contract fields, matching diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index ecff1dcd3c..a54203f5f3 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -16,6 +16,7 @@ import { } from "@/lib/source-governance"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { logger } from "@/lib/logger"; export const runtime = "nodejs"; @@ -78,14 +79,14 @@ function streamErrorPayload(error: unknown) { } function logStreamError(error: unknown) { - console.error("Search stream failed", { + logger.error("Search stream failed", { name: error instanceof Error ? error.name : typeof error, message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } -function streamAnswer(body: AnswerBody, ownerId?: string) { +function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal) { const encoder = new TextEncoder(); return new Response( @@ -155,12 +156,15 @@ function streamAnswer(body: AnswerBody, ownerId?: string) { queryMode: body.queryMode, skipCache: body.skipCache, onProgress, + signal, }); const warnings = sourceGovernanceWarnings({ results: answer.sources ?? [], relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null, }); - if (hasDangerSourceGovernanceWarning(warnings)) { + const shouldUseSourceGovernanceRefusal = + answer.grounded !== false && answer.confidence !== "unsupported" && answer.responseMode !== "evidence_gap"; + if (shouldUseSourceGovernanceRefusal && hasDangerSourceGovernanceWarning(warnings)) { // Explicit refusal payload — do not spread ...answer (see /api/answer): // the refused sources/smartPanel/smartApiPlan must not reach the client. send("final", { @@ -202,7 +206,7 @@ function streamAnswer(body: AnswerBody, ownerId?: string) { export async function POST(request: Request) { try { const body = answerSchema.parse(await request.json()); - if (isDemoMode()) return streamAnswer(body); + if (isDemoMode()) return streamAnswer(body, undefined, request.signal); const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); @@ -215,7 +219,7 @@ export async function POST(request: Request) { }); if (rateLimit.limited) return rateLimitStream(rateLimit); - return streamAnswer(body, user.id); + return streamAnswer(body, user.id, request.signal); } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(error); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 95799522de..b8d8483c7e 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -7,6 +7,7 @@ import { invalidateRagCachesForDocumentMutation } from "@/lib/rag"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { writeAuditLog } from "@/lib/audit"; export const runtime = "nodejs"; @@ -460,6 +461,13 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id if (updateError) throw new Error(updateError.message); invalidateRagCachesForDocumentMutation(user.id); + await writeAuditLog(supabase, { + ownerId: user.id, + action: "document_rename", + resourceType: "document", + resourceId: id, + metadata: { previousTitle: document.title, newTitle: parsed.data.title }, + }); return NextResponse.json({ document: updated }); } catch (error) { if (error instanceof AuthenticationError) { @@ -568,6 +576,13 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i if (ledgerWarning) cleanup.storageWarnings.push(ledgerWarning); invalidateRagCachesForDocumentMutation(user.id); + await writeAuditLog(supabase, { + ownerId: user.id, + action: "document_delete", + resourceType: "document", + resourceId: id, + metadata: { title: document.title, storageRemoved: cleanup.storageRemoved }, + }); return NextResponse.json({ deleted: true, documentId: id, diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000000..f70d0264b1 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { env, isDemoMode } from "@/lib/env"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Liveness/readiness probe for load balancers and uptime monitors. It reports +// whether the server is configured to operate for real (Supabase + OpenAI present) +// and, with ?deep=1, whether Supabase is actually reachable. The payload exposes only +// boolean configuration presence and operational status — never secret values. +export async function GET(request: Request) { + const deep = new URL(request.url).searchParams.get("deep") === "1"; + const supabaseConfigured = Boolean(env.NEXT_PUBLIC_SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY); + + const checks: Record = { + supabaseConfig: supabaseConfigured ? "ok" : "missing", + openaiConfig: env.OPENAI_API_KEY ? "ok" : "missing", + }; + + if (deep) { + if (supabaseConfigured && !isDemoMode()) { + try { + const [{ createAdminClient }, { probeSupabaseHealth }] = await Promise.all([ + import("@/lib/supabase/admin"), + import("@/lib/supabase/health"), + ]); + const health = await probeSupabaseHealth(createAdminClient()); + checks.supabase = health.ok ? "ok" : "error"; + } catch { + checks.supabase = "error"; + } + } else { + checks.supabase = "skipped"; + } + } + + const ready = !Object.values(checks).includes("missing") && !Object.values(checks).includes("error"); + + return NextResponse.json( + { + status: ready ? "ok" : "degraded", + demoMode: isDemoMode(), + timestamp: new Date().toISOString(), + uptimeSeconds: Math.round(process.uptime()), + checks, + }, + { status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index b3f3188c7d..dc2730df6e 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto"; import { createHash } from "node:crypto"; import { NextResponse } from "next/server"; import { env } from "@/lib/env"; -import { assertAllowedFile, jsonError } from "@/lib/http"; +import { assertAllowedFile, assertFileContentSignature, jsonError } from "@/lib/http"; +import { logger } from "@/lib/logger"; +import { writeAuditLog } from "@/lib/audit"; import { planDocumentName, type SupabaseLike } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -29,6 +31,9 @@ export async function POST(request: Request) { const safeName = file.name.replace(/[^\w.\-() ]+/g, "_"); const storagePath = `${user.id}/documents/${documentId}/${safeName}`; const buffer = Buffer.from(await file.arrayBuffer()); + // The declared MIME type is client-supplied; verify the real byte signature + // before persisting a clinical document. + assertFileContentSignature(file.type, buffer); const contentHash = createHash("sha256").update(buffer).digest("hex"); const { data: duplicate, error: duplicateError } = await supabase @@ -128,13 +133,26 @@ export async function POST(request: Request) { if (jobError) throw new Error(jobError.message); + await writeAuditLog(supabase, { + ownerId: user.id, + action: "document_upload", + resourceType: "document", + resourceId: documentId, + metadata: { fileName: file.name, fileType: file.type, fileSize: file.size, contentHash }, + }); + return NextResponse.json({ document, job }, { status: 201 }); } catch (error) { if (uploadedPath && supabase) { try { await supabase.storage.from(env.SUPABASE_DOCUMENT_BUCKET).remove([uploadedPath]); - } catch { - // Preserve the original upload error response; cleanup is best-effort. + } catch (cleanupError) { + // Cleanup is best-effort, but a silent failure leaves an orphaned storage + // object. Record the path so it can be reconciled instead of dropping it. + logger.error("Upload cleanup failed; storage object may be orphaned", { + storagePath: uploadedPath, + message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); } } diff --git a/src/app/globals.css b/src/app/globals.css index e4137b84d9..1bf8d2c5ac 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -33,6 +33,7 @@ --animate-sheet-up: sheet-up 250ms cubic-bezier(0.22, 1, 0.36, 1) both; --animate-sheet-left: sheet-left 220ms cubic-bezier(0.22, 1, 0.36, 1) both; --animate-pop-in: pop-in 180ms cubic-bezier(0.34, 1.3, 0.64, 1) both; + --animate-action-tray-in: action-tray-in 160ms cubic-bezier(0.22, 1, 0.36, 1) both; --animate-shimmer: shimmer 1.4s linear infinite; } @@ -473,6 +474,17 @@ summary::-webkit-details-marker { } } +@keyframes action-tray-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + @keyframes shimmer { from { background-position: -150% 0; diff --git a/src/app/mockups/evidence-option/page.tsx b/src/app/mockups/evidence-option/page.tsx index 1c1108b856..1c70d20ede 100644 --- a/src/app/mockups/evidence-option/page.tsx +++ b/src/app/mockups/evidence-option/page.tsx @@ -1,769 +1,694 @@ import Image from "next/image"; import type { Metadata } from "next"; import { - ArrowRight, + Activity, BadgeCheck, BookOpen, - CheckCircle2, + Check, + ChevronDown, ChevronRight, + ClipboardCheck, Copy, ExternalLink, - Eye, FileImage, FileSearch, FileText, Filter, + Image as ImageIcon, Layers3, - Library, - ListChecks, - PanelRightOpen, + Link2, + Menu, + Mic, + Plus, Quote, Search, + Send, ShieldCheck, - Sparkles, - SplitSquareHorizontal, Table2, - Target, + X, type LucideIcon, } from "lucide-react"; import type { ReactNode } from "react"; export const metadata: Metadata = { - title: "Premium Evidence Mockups - Clinical KB", - description: "Premium ChatGPT-style evidence workspace mockups.", + title: "Evidence Option Mockups - Clinical KB", + description: "Premium Clinical KB evidence mockups for desktop and mobile.", }; -type Tone = "green" | "amber" | "blue" | "graphite"; +type EvidenceObject = { + title: string; + type: "Table" | "Quote" | "Image" | "PDF region"; + source: string; + page: string; + support: string; + detail: string; + icon: LucideIcon; +}; -const evidenceItems = [ +const objects: EvidenceObject[] = [ { title: "ANC monitoring frequency table", - kind: "Table", + type: "Table", source: "Clozapine physical health protocol", - meta: "p. 12 - 97% direct support", - summary: "Baseline and ongoing blood monitoring cadence with hold thresholds.", + page: "p.12", + support: "97% direct", + detail: "Baseline checks, weekly cadence, and escalation thresholds.", icon: Table2, - tone: "green", }, { title: "Constipation escalation passage", - kind: "Quote", + type: "Quote", source: "Clozapine safety bulletin", - meta: "p. 4 - exact quote", - summary: "Same-day review wording for severe constipation and abdominal symptoms.", + page: "p.4", + support: "Exact quote", + detail: "Same-day review wording for severe constipation symptoms.", icon: Quote, - tone: "blue", }, { title: "Observation pathway crop", - kind: "Image", + type: "Image", source: "Acute behavioural disturbance pathway", - meta: "p. 8 - visual extraction", - summary: "Diagram labels, observation frequency, and escalation checkpoints.", + page: "p.8", + support: "Visual evidence", + detail: "Flow diagram, labels, and adjacent explanatory text.", icon: FileImage, - tone: "graphite", - }, - { - title: "Lithium toxicity threshold span", - kind: "Document span", - source: "Lithium monitoring guideline", - meta: "p. 6 - review due", - summary: "Older local passage retained with date and governance warning.", - icon: FileText, - tone: "amber", }, +]; + +const objectCounts = [ + ["Tables", "621", Table2], + ["Quotes", "8,904", Quote], + ["Images", "2,840", ImageIcon], + ["PDF regions", "4,221", FileSearch], + ["Documents", "1,834", FileText], ] as const; const tableRows = [ ["Baseline", "FBC, LFT, U&E, lipids, glucose", "Before initiation"], - ["Weekly", "FBC/ANC", "Initial titration phase"], - ["Escalate", "Chest pain, fever, severe constipation", "Urgent medical review"], + ["Weekly", "FBC/ANC", "Initial titration"], + ["Escalate", "Chest pain, fever, constipation", "Urgent review"], ] as const; -const sourceKinds = [ - ["Tables", "621", Table2], - ["Quotes", "8,904", Quote], - ["Images", "2,840", FileImage], - ["PDF regions", "4,221", FileSearch], - ["Document pages", "1,834", FileText], -] as const; +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; -function tone(t: Tone) { - if (t === "green") return "border-emerald-200 bg-emerald-50 text-emerald-800"; - if (t === "amber") return "border-amber-200 bg-amber-50 text-amber-800"; - if (t === "blue") return "border-sky-200 bg-sky-50 text-sky-800"; - return "border-zinc-200 bg-zinc-100 text-zinc-700"; +function cn(...classes: Array) { + return classes.filter(Boolean).join(" "); } -function Badge({ children, variant = "graphite" }: { children: ReactNode; variant?: Tone }) { +function IconTile({ icon: Icon, strong = false }: { icon: LucideIcon; strong?: boolean }) { return ( - {children} + ); } -function Glyph({ icon: Icon, variant = "graphite" }: { icon: LucideIcon; variant?: Tone }) { +function Pill({ + children, + active = false, + tone = "neutral", +}: { + children: ReactNode; + active?: boolean; + tone?: "neutral" | "success" | "warning"; +}) { return ( - - + + {children} ); } -function GhostButton({ +function ActionButton({ children, icon: Icon, primary = false, }: { children: ReactNode; - icon?: LucideIcon; + icon: LucideIcon; primary?: boolean; }) { return ( ); } -function Section({ title, body, children }: { title: string; body: string; children: ReactNode }) { +function Composer({ placeholder = "Ask a clinical question..." }: { placeholder?: string }) { return ( -
-
-

Premium evidence direction

-

{title}

-

{body}

-
-
{children}
-
+
+ + {placeholder} + + +
); } -function Frame({ label, title, children }: { label: string; title: string; children: ReactNode }) { +function DesktopChrome({ children, mode = "Evidence" }: { children: ReactNode; mode?: string }) { return ( -
-
-
-

{label}

-

{title}

+
+
+ +
+ + + + + Mode + {mode} + +
- - - Premium mockup - -
-
- {children} +
+ + + + AK + +
+ +
+ +
+ {children} +
+ +
+
-
- ); -} - -function SearchBar({ placeholder }: { placeholder: string }) { - return ( -
- - {placeholder} - - Ask evidence -
); } -function EvidenceCard({ item, compact = false }: { item: (typeof evidenceItems)[number]; compact?: boolean }) { +function PhoneChrome({ children, sheet = false }: { children: ReactNode; sheet?: boolean }) { return ( -
-
- -
-
- {item.kind} - {item.meta} -
-

{item.title}

-

{item.summary}

- {!compact ?

{item.source}

: null} +
+
+ +
+ + + + + Mode + Evidence + +
- + +
+
{children}
+
+
); } -function EvidenceTable() { +function MockupPair({ + id, + title, + description, + desktop, + phone, +}: { + id: string; + title: string; + description: string; + desktop: ReactNode; + phone: ReactNode; +}) { return ( -
-
- Stage - Evidence - Action -
- {tableRows.map(([stage, evidence, action]) => ( -
- {stage} - {evidence} - {action} +
+
+
+

Evidence option

+

{title}

+

{description}

- ))} -
- ); -} - -function TopNav() { - return ( -
-
- - - - Clinical KB Evidence + PC + phone
-
- {["Home", "Search", "Tables", "Images", "Quotes"].map((item, index) => ( - - {item} - - ))} +
+ {desktop} +
{phone}
-
+
); } -function HomeOne() { +function EvidenceObjectRow({ item, selected = false }: { item: EvidenceObject; selected?: boolean }) { + const Icon = item.icon; return ( - -
- -
-
-
-

Evidence workspace

-

- Ask across every document part with source-grade confidence. -

-

- Search images, extracted tables, exact quotes, PDF regions, and source spans without leaving the answer - workflow. -

-
- -
-
-
- {sourceKinds.slice(0, 3).map(([label, count, Icon], index) => ( -
- -

{label}

-

{count}

-
- ))} -
-
- +
+ +
+
+

{item.title}

+ {item.type}
+

+ {item.source} - {item.page} - {item.support} +

+

{item.detail}

- + +
); } -function HomeTwo() { +function EvidenceHomeDesktop() { return ( - -
- -
- -
-
- -

Tables as first-class evidence

-

- Extracted rows, image crop, source page, confidence, and quote-level provenance in one place. -

-
- + +
+ +

Evidence

+

+ Find, inspect, and reuse exact tables, images, quotes, PDF regions, and document spans. +

+
+ {[ + ["Search evidence objects", "Search every extracted table, quote, figure, and page region", Search], + ["Review recent evidence", "Continue checking the objects used in recent answers", ClipboardCheck], + ["Open source map", "Follow an evidence object back through document, section, and page", Link2], + ].map(([title, body, Icon]) => ( +
+ +
+

{title as string}

+

{body as string}

+
-
- -

Images with inspection tools

-

- Page crops, figure labels, region highlights, and source text shown together. -

- Risk flow evidence preview -
-
+ ))}
-
- - ); -} - -function HomeThree() { - return ( - -
-
-
- - - -
-

What evidence do you need?

-

- Ask for a document part directly, then refine by source, date, type, confidence, or clinical domain. -

-
- {[ - "Show the table behind ANC monitoring", - "Find the exact constipation quote", - "Open the pathway image and neighbouring text", - ].map((prompt) => ( - - ))} +
+ {objectCounts.slice(0, 3).map(([label, count, Icon]) => ( +
+
+ + {count}
+

{label}

-
+ ))}
-
- + ); } -function SearchOne() { +function EvidenceHomePhone() { return ( - -
- -
- {sourceKinds.map(([label, count, Icon], index) => ( - + +
+ +

Evidence

+

Open the exact object behind an answer.

+
+ {[ + ["Search evidence", Search], + ["Recent objects", ClipboardCheck], + ["Source map", Link2], + ].map(([label, Icon]) => ( +
+ +

{label as string}

+ +
))}
-
-
- {evidenceItems.map((item) => ( - - ))} -
- +
+

Recent

+

ANC monitoring frequency table

+

p.12 - 97% direct

- + ); } -function SearchTwo() { +function EvidenceSearchDesktop() { return ( - -
- -
- -
- {evidenceItems.map((item) => ( - +
+ {objects.map((item, index) => ( + ))}
-
+
+

Source trail

+
+ Clozapine protocol + + Monitoring + + Table 2 +
+
+ +
- + ); } -function SearchThree() { +function EvidenceSearchPhone() { return ( - -
-
-

Evidence query

-
-

What supports weekly ANC monitoring during clozapine initiation?

- - Build evidence pack - -
+ +
+
+ + clozapine table
-
- {[ - { label: "Direct support", count: "9 items", icon: Target, tone: "green" }, - { label: "Nearby context", count: "11 items", icon: Layers3, tone: "blue" }, - { label: "Conflicts / gaps", count: "3 items", icon: ShieldCheck, tone: "amber" }, - ].map(({ label, count, icon, tone: variant }) => ( -
- -

{label}

-

{count}

-
+
+ {objects.slice(0, 2).map((item) => ( + ))}
- - ); -} - -function DetailHeader({ - title, - kind, - icon, - variant, -}: { - title: string; - kind: string; - icon: LucideIcon; - variant: Tone; -}) { - return ( -
-
- -
-
- {kind} - - - Source-backed - - p. 12 +
+ +
+ +
+

ANC monitoring table

+

p.12 - 97% direct

-

{title}

-

- Clozapine physical health protocol - indexed June 2026 - locally reviewed source. -

+
-
-
- Copy citation - - Open source - -
-
- ); -} - -function DetailTable() { - return ( - -
- -
-
- -
-

Extracted source image

- Extracted clozapine monitoring table evidence +
+ {tableRows.map(([stage, evidence]) => ( +
+ {stage} + {evidence}
-
- + ))} +
+
+ Open + Copy
- + ); } -function DetailImage() { +function EvidenceDetailDesktop() { return ( - -
- -
-
-
-

Page crop and labels

- Visual extraction + +
+
+
+
+
+ +
+

Constipation escalation passage

+

Clozapine safety bulletin - p.4 - exact extracted quote

+
+
+ + Reviewed +
- Extracted risk flow image evidence -
- -
+
+
+ Clozapine monitoring table evidence crop +

Linked table

+
+
+ Risk flow evidence image crop +

Linked image

+
+
+

Document context

+

Previous and next paragraphs retained with page coordinates.

+
+
+
+
- + ); } -function DetailQuote() { +function EvidenceDetailPhone() { return ( - -
- -
-
- {[ - "Patients reporting severe constipation, abdominal pain, vomiting, or reduced bowel motions require same-day clinical review.", - "Monitoring frequency should be increased where clinical status changes, supply is interrupted, or blood results approach action thresholds.", - ].map((quote, index) => ( -
-
"{quote}"
-
- Clozapine safety bulletin, p. {index === 0 ? "4" : "12"} -
-
- ))} -
- + +
+
+ Quote + Image + Context +
+
+
+
+
+ "Patients reporting severe constipation, abdominal pain, vomiting, or reduced bowel motions require same-day clinical review." +
+
+
+ Risk flow evidence image crop +
+
+ Open + Copy quote +
- + ); } export default function EvidenceOptionMockupsPage() { return ( -
-
-
-
+
+
+
+
-
- - - -

- Clinical KB evidence option -

+
+ +

Clinical KB evidence option

-

- Premium mockups for document evidence -

-

- A completely new ChatGPT premium direction for tables, images, quotes, PDF regions, source packs, and - document spans. +

Premium evidence mockups

+

+ Three product-native mockups for evidence home, evidence search, and individual evidence review. Each pairs the PC layout with the phone bottom-sheet treatment.

-
- - Search evidence - - Browse library - Review sources -
-
-

Included

-
- {[ - ["Home", "3 premium entry directions"], - ["Search", "3 result workflows"], - ["Detail", "3 evidence-type pages"], - ].map(([label, body]) => ( -
-

{label}

-

{body}

-
- ))} -
+
+ Source-backed + Tables + Images + Quotes
-
- - - -
+ } + phone={} + /> -
- - - -
+ } + phone={} + /> -
- - - -
+ } + phone={} + />
); diff --git a/src/app/mockups/settings-search-privacy/page.tsx b/src/app/mockups/settings-search-privacy/page.tsx index 5e7c50c9bc..f29dd5d2ae 100644 --- a/src/app/mockups/settings-search-privacy/page.tsx +++ b/src/app/mockups/settings-search-privacy/page.tsx @@ -3,10 +3,10 @@ import type { Metadata } from "next"; import { SettingsSearchMockupPage } from "@/components/settings-search-mockups"; export const metadata: Metadata = { - title: "Search Settings Privacy Mockup - Clinical KB", - description: "Search-first ChatGPT-style privacy and personalization settings mockup for Clinical KB.", + title: "Premium Account Hub Mockup - Clinical KB", + description: "Premium ChatGPT-style account and app hub settings mockup for Clinical KB.", }; -export default function SettingsSearchPrivacyMockupRoute() { - return ; +export default function PremiumAccountHubMockupRoute() { + return ; } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 68f6f29753..be179c5bb8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -7,31 +7,34 @@ import { useRouter } from "next/navigation"; import { Activity, AlertCircle, + Bell, BookOpen, - Brain, CheckCircle2, ChevronDown, + CircleUserRound, ClipboardCheck, - ClipboardList, Copy, ExternalLink, FileImage, FileText, Filter, + Globe2, Heart, - HeartHandshake, + HelpCircle, + Keyboard, Layers, - LayoutList, ListChecks, Loader2, LogIn, LogOut, + LockKeyhole, Mail, MessageSquare, MoreHorizontal, - Network, + Palette, PanelLeftClose, PanelLeftOpen, + PanelTop, Pill, Plus, Quote, @@ -42,10 +45,12 @@ import { ShieldCheck, SlidersHorizontal, Sparkles, + Stethoscope, Table2, Tag, Target, UploadCloud, + UserRound, WifiOff, Wrench, X, @@ -68,6 +73,7 @@ import { } from "@/components/DocumentOrganizationBadges"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { documentCitationHref, formatCompactCitationLabel, formatCitationLabel } from "@/lib/citations"; import { extractSafetyFindings, formatSafetyFindingLabel } from "@/lib/clinical-safety"; import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; @@ -111,7 +117,6 @@ import { tableCardHeader, tableMicroActionRow, textMuted, - toolbarButton, toneDanger, toneInfo, toneNeutral, @@ -142,6 +147,7 @@ import { MasterSearchHeader } from "@/components/clinical-dashboard/master-searc import { FavouritesHub } from "@/components/clinical-dashboard/favourites-hub"; import { favouritePrototypeCount } from "@/components/clinical-dashboard/favourites-prototype-data"; import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; +import { ApplicationsLauncherWorkspace, applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { DocumentSearchResultsPanel, MatchExplanationChips, @@ -181,7 +187,6 @@ import { logSourceOpen, SourceActionRow, sourceResultHref } from "@/components/c import { clinicalProseUsefulness, sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; import { groupSourceGovernanceWarnings, type SourceGovernanceWarning } from "@/lib/source-governance"; import { smartEvidenceTags } from "@/lib/evidence-tags"; -import { toolCatalog, type ToolItem } from "@/lib/tools"; import { reviewDocumentTagQuality, tagSearchText, @@ -1235,40 +1240,135 @@ function compactClinicalNoteText(value: string) { .trim(); } +function stripClinicalNoteLeadIn(value: string) { + let text = compactClinicalNoteText(value); + let previous = ""; + while (text !== previous) { + previous = text; + text = text + .replace(/^(the\s+same\s+)?synthetic\s+source\s+says\s+/i, "") + .replace(/^the\s+(indexed\s+)?source\s+says\s+/i, "") + .replace(/^source\s+text\s+says\s+/i, "") + .replace(/^according\s+to\s+[^,]+,\s*/i, "") + .trim(); + } + return text; +} + +function titleCaseClinicalNote(value: string) { + return value + .replace(/\b\w[\w/-]*/g, (word) => { + if (/[A-Z]{2,}|\/|\d/.test(word)) return word; + return `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`; + }) + .replace(/\bAnd\b/g, "and") + .replace(/\bOr\b/g, "or") + .replace(/\bTo\b/g, "to"); +} + +function sentenceCaseClinicalNoteDetail(value: string) { + const text = stripClinicalNoteLeadIn(value); + return text ? `${text.charAt(0).toUpperCase()}${text.slice(1)}` : text; +} + +function clinicalNoteHeuristicTitle(value: string) { + const text = stripClinicalNoteLeadIn(value); + const lower = text.toLowerCase(); + + if (/\bbaseline checklist\b/.test(lower) && /\bconfirm indication\b/.test(lower)) return "Indication"; + if (/\b(vomiting|diarrhoea|diarrhea|dehydration|acute kidney injury|tremor|confusion|ataxia)\b/.test(lower)) { + return "Toxicity review triggers"; + } + if ( + /\b(escalate|urgent review|urgent|red flag|seizures?|severe constipation|chest pain|dyspnoea|tachycardia)\b/.test( + lower, + ) + ) { + return "Escalation triggers"; + } + if (/\blithium levels?\b/.test(lower) && /\b(5\s*(?:to|-|–)\s*7|dose change|stable|days?)\b/.test(lower)) { + return "Lithium level timing"; + } + if (/\b(lithium level|serum lithium|trough level)\b/.test(lower)) return "Lithium level check"; + if (/\b(fbc|anc)\b/.test(lower)) return "FBC/ANC monitoring"; + if (/\bmyocarditis\b/.test(lower)) return "Myocarditis screening"; + if (/\b(metabolic|weight|lipids?|glucose|hba1c|waist)\b/.test(lower)) return "Metabolic monitoring"; + if (/\b(constipation|bowel)\b/.test(lower)) return "Constipation prevention"; + if (/\b(shared-care|shared care|communication|handover)\b/.test(lower)) return "Shared-care communication"; + if (/\b(renal|kidney|creatinine|egfr)\b/.test(lower)) return "Renal function"; + if (/\b(thyroid|tsh)\b/.test(lower)) return "Thyroid monitoring"; + if (/\bcalcium\b/.test(lower)) return "Calcium monitoring"; + if (/\b(nsaid|ace inhibitor|diuretic|interacting medicine|medicine reconciliation)\b/.test(lower)) { + return "Interacting medicines"; + } + + return null; +} + function clinicalNoteTitleFromItem(item: string, section: ClinicalDetailSection, index: number) { - const text = compactClinicalNoteText(item); + const text = stripClinicalNoteLeadIn(item); + const heuristicTitle = clinicalNoteHeuristicTitle(text); + if (heuristicTitle) return heuristicTitle; const colonIndex = text.indexOf(":"); - if (colonIndex > 8 && colonIndex < 54) return text.slice(0, colonIndex).trim(); + if (colonIndex > 8 && colonIndex < 54) { + const title = text.slice(0, colonIndex).trim(); + const detailStart = text + .slice(colonIndex + 1) + .split(/[,;]/)[0] + ?.trim(); + if (/\b(checklist|checkpoint|points?)\b/i.test(title) && detailStart) { + return clinicalNoteTitleFromFragment(detailStart); + } + return title; + } const dashIndex = text.search(/\s[-–]\s/); if (dashIndex > 8 && dashIndex < 54) return text.slice(0, dashIndex).trim(); if (section.items.length === 1 && section.title.length <= 42) return section.title; - const words = text.split(" ").filter(Boolean); + const words = text + .replace(/^(confirm|check|review|record|document)\s+/i, "") + .split(" ") + .filter(Boolean); return words.slice(0, Math.min(words.length, index === 0 ? 5 : 4)).join(" ") || section.title; } function clinicalNoteDetailFromItem(item: string, title: string) { - const text = compactClinicalNoteText(item); + const text = stripClinicalNoteLeadIn(item); const normalizedTitle = title.toLowerCase(); const lowerText = text.toLowerCase(); - if (lowerText.startsWith(`${normalizedTitle}:`)) return text.slice(title.length + 1).trim(); + const colonIndex = text.indexOf(":"); + if (colonIndex > 8 && colonIndex < 64) { + const beforeColon = text.slice(0, colonIndex); + const afterColon = text.slice(colonIndex + 1).trim(); + if (/\b(checklist|checkpoint|points?)\b/i.test(beforeColon) && afterColon) { + return sentenceCaseClinicalNoteDetail(afterColon); + } + } + if (lowerText.startsWith(`${normalizedTitle}:`)) { + return sentenceCaseClinicalNoteDetail(text.slice(title.length + 1).trim()); + } if (lowerText.startsWith(`${normalizedTitle} -`) || lowerText.startsWith(`${normalizedTitle} –`)) { - return text.slice(title.length + 2).trim(); + return sentenceCaseClinicalNoteDetail(text.slice(title.length + 2).trim()); } - return text === title ? "Review linked source context before using this note." : text; + if (text === title) return "Review linked source context before using this note."; + return sentenceCaseClinicalNoteDetail(text); } function clinicalNoteTitleFromFragment(fragment: string) { - const text = compactClinicalNoteText(fragment) + const text = stripClinicalNoteLeadIn(fragment) .replace(/^(and|or)\s+/i, "") + .replace(/^(confirm|check|review|record|document)\s+/i, "") .replace(/[.;:,]+$/g, ""); if (!text) return "Clinical note"; - return text.replace(/\b\w/g, (letter) => letter.toUpperCase()); + return clinicalNoteHeuristicTitle(text) ?? titleCaseClinicalNote(text); } function splitClinicalNoteFragments(item: string, section: ClinicalDetailSection, title: string) { const detail = clinicalNoteDetailFromItem(item, title); const titleLooksGeneric = /\b(checkpoint|checklist|item|point|monitoring|safety)\b/i.test(title); - if (!titleLooksGeneric && section.items.length > 1) return null; + const itemLooksGeneric = /\b(checkpoint|checklist|item|point|monitoring|safety)\b/i.test( + stripClinicalNoteLeadIn(item), + ); + if (!titleLooksGeneric && !itemLooksGeneric && section.items.length > 1) return null; const fragments = detail .replace(/\band\s+/gi, "") @@ -1292,6 +1392,28 @@ function clinicalNoteHasDistinctDetail(row: ClinicalNotesRow) { return Boolean(detail) && detail !== title; } +function clinicalNoteDetailLabel(row: ClinicalNotesRow) { + const text = `${row.title} ${row.detail}`.toLowerCase(); + if (/\b(timing|level|schedule|dose change|stable|days?)\b/.test(text)) return "Timing"; + if (/\b(escalation|escalate|urgent|toxicity|trigger|vomiting|confusion|ataxia|tremor)\b/.test(text)) { + return "Escalate"; + } + if (/\b(baseline|confirm|record|document|check|review)\b/.test(text)) return "Action"; + return "Note"; +} + +function ClinicalNoteDetailCard({ row }: { row: ClinicalNotesRow }) { + const detail = sentenceCaseClinicalNoteDetail(row.detail); + return ( +
+

+ {clinicalNoteDetailLabel(row)}: + {detail} +

+
+ ); +} + function clinicalNotesTableEvidenceCount(answer: RagAnswer) { return (answer.visualEvidence ?? answer.smartPanel?.visualEvidence ?? []).filter( (item) => item.accessibleTableMarkdown || item.tableRows?.length, @@ -1396,6 +1518,7 @@ function ClinicalNotesChecklistPanel({ const firstExpandableRow = rows.find(clinicalNoteHasDistinctDetail) ?? null; const activeRow = rows.find((row) => row.id === expandedRowId) ?? firstExpandableRow; const [added, setAdded] = useState(false); + const warningCount = rows.filter((row) => row.tone === "warn").length; const toggles: Array<{ id: ClinicalNotesTabId | "table"; label: string; @@ -1416,165 +1539,165 @@ function ClinicalNotesChecklistPanel({ } const ActiveIcon = clinicalNotesTabMeta[activeTab].icon; + const showToggleBar = toggles.length > 1; return ( -
-
-
- {toggles.map((tab) => { - const Icon = tab.icon; - const selected = !tab.popout && tab.id === activeTab; - return ( - - ); - })} +
+ {showToggleBar ? ( +
+
+ {toggles.map((tab) => { + const Icon = tab.icon; + const selected = !tab.popout && tab.id === activeTab; + return ( + + ); + })} +
-
+ ) : null} -
+
-

+

{activeTab === "safety" ? "Safety checklist" : "Monitoring checklist"}

-

+

{activeTab === "safety" - ? "Key actions to start and continue safely." + ? warningCount > 0 + ? `${warningCount} caution ${warningCount === 1 ? "item" : "items"} prioritised from the answer.` + : "Key actions to start and continue safely." : "Monitoring and medication follow-up items."}

-
+
{rows.map((row) => { const expanded = row.id === activeRow?.id; const hasDistinctDetail = clinicalNoteHasDistinctDetail(row); const RowIcon = row.tone === "warn" ? AlertCircle : CheckCircle2; return ( -
- {expanded ? ( - - ) : null} +
- {expanded && hasDistinctDetail ? ( -
-

{row.detail}

-
- ) : null} + {expanded && hasDistinctDetail ? : null}
); })}
-
+
{bestSource ? ( - + Source ) : ( - - + + Source )}
@@ -3747,20 +3870,23 @@ function StagedAnswerResultSurface({ mobileSummary={`${clinicalNoteDisplayCount} note${clinicalNoteDisplayCount === 1 ? "" : "s"} · Source-backed`} className={clinicalNotesRow} open={clinicalNotesOpen} - onOpenChange={setClinicalNotesOpen} + onOpenChange={(open) => { + if (open) setEvidenceOpen(false); + setClinicalNotesOpen(open); + }} sheetHeaderLeading={ - - + + } sheetTitleAccessory={ - + {clinicalNoteDisplayCount} } sheetDescriptionContent={ - - + + Source-backed } @@ -3768,17 +3894,21 @@ function StagedAnswerResultSurface({ bestSource ? ( - + ) : null } sheetDescription={null} + sheetHeaderClassName="gap-2 p-2.5 sm:p-3" + sheetTitleClassName="text-[15px] leading-5" + sheetCloseButtonClassName="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)]" + sheetChildrenClassName="flex min-h-0 flex-1 flex-col" sheetContentClassName="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" sheetContentStyle={{ height: "80dvh" }} - sheetBodyClassName="bg-[color:var(--surface-raised)] px-5 pb-0 pt-5 sm:p-5" + sheetBodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" > { + if (open) setClinicalNotesOpen(false); setEvidenceOpen(open); if (!open) setEvidenceInitialTab(null); }} @@ -5330,12 +5461,19 @@ function DrawerGroupLabel({ title }: { title: string }) { } const sidebarToolItems = [ - { label: "Answer", icon: Sparkles, href: "/?mode=answer" }, - { label: "Documents", icon: FileText, href: "/?mode=documents" }, - { label: "Meds", icon: Pill, href: "/?mode=prescribing" }, - { label: "Applications", icon: ListChecks, href: "/applications" }, + { id: "answer", label: "Answer", icon: Sparkles, href: "/?mode=answer" }, + { id: "documents", label: "Documents", icon: FileText, href: "/?mode=documents" }, + { id: "prescribing", label: "Meds", icon: Pill, href: "/?mode=prescribing" }, + { id: "tools", label: "Tools", icon: Wrench, href: "/?mode=tools" }, ] as const; +const collapsedSidebarButton = + "grid h-11 w-11 shrink-0 place-items-center rounded-xl border border-transparent text-[color:var(--text-muted)] transition hover:border-[color:var(--border)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; +const collapsedSidebarActiveButton = + "border-[color:var(--clinical-chat-teal)]/22 bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]"; +const collapsedSidebarPrimaryButton = + "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)] hover:border-[color:var(--clinical-chat-teal)]/35 hover:text-[color:var(--clinical-chat-teal)]"; + type SidebarIdentity = { displayName: string; initials: string; @@ -5359,6 +5497,7 @@ function deriveSidebarIdentity(email: string | null | undefined): SidebarIdentit function ClinicalSidebarContent({ recentQueries, identity, + activeMode, onNewChat, onPickRecent, onOpenGuide, @@ -5370,6 +5509,7 @@ function ClinicalSidebarContent({ }: { recentQueries: string[]; identity: SidebarIdentity; + activeMode: AppModeId; onNewChat: () => void; onPickRecent: (query: string) => void; onOpenGuide: () => void; @@ -5477,22 +5617,26 @@ function ClinicalSidebarContent({
-

- Applications -

+

Tools

{sidebarToolItems.map((item) => { const Icon = item.icon; + const active = activeMode === item.id; return ( {item.label} @@ -5501,14 +5645,14 @@ function ClinicalSidebarContent({ })}
- View applications + View tools
@@ -5536,7 +5680,16 @@ function ClinicalSidebarContent({ Settings -
+
+
); @@ -5559,6 +5712,7 @@ function ClinicalDesktopSidebar({ collapsed, recentQueries, identity, + activeMode, onCollapsedChange, onNewChat, onPickRecent, @@ -5569,6 +5723,7 @@ function ClinicalDesktopSidebar({ collapsed: boolean; recentQueries: string[]; identity: SidebarIdentity; + activeMode: AppModeId; onCollapsedChange: (collapsed: boolean) => void; onNewChat: () => void; onPickRecent: (query: string) => void; @@ -5580,53 +5735,81 @@ function ClinicalDesktopSidebar({ return ( ); } @@ -5640,6 +5823,7 @@ function ClinicalDesktopSidebar({ void; onNewChat: () => void; onPickRecent: (query: string) => void; @@ -5686,6 +5872,7 @@ function ClinicalMobileSidebar({ showHeader={false} recentQueries={recentQueries} identity={identity} + activeMode={activeMode} onNewChat={onNewChat} onPickRecent={onPickRecent} onOpenGuide={onOpenGuide} @@ -5714,318 +5901,324 @@ function SettingsDialog({ onSignOut: () => void; onOpenGuide: () => void; }) { - const themeOptions: Array<{ value: "light" | "dark"; label: string }> = [ - { value: "light", label: "Light" }, - { value: "dark", label: "Dark" }, + const closeButtonRef = useRef(null); + const currentThemeLabel = theme === "dark" ? "Dark" : "Light"; + const settingSections = [ + { + title: "Account", + rows: [ + { icon: UserRound, label: "Profile", value: identity.displayName }, + { icon: Stethoscope, label: "Clinical role", value: "Consultant psychiatrist" }, + ], + }, + { + title: "Clinical defaults", + rows: [ + { icon: Globe2, label: "Jurisdiction", value: "Western Australia", active: true }, + { icon: CircleUserRound, label: "Default population", value: "Adults" }, + { icon: SlidersHorizontal, label: "Answer style", value: "Conservative" }, + ], + }, + { + title: "App preferences", + rows: [ + { + icon: Palette, + label: "Appearance", + value: currentThemeLabel, + onClick: onToggleTheme, + actionLabel: `Switch to ${theme === "dark" ? "light" : "dark"} mode`, + }, + { icon: SettingsIcon, label: "Interface density", value: "Comfortable" }, + ], + }, + ]; + const navItems = [ + { icon: SettingsIcon, label: "General" }, + { icon: Stethoscope, label: "Clinical defaults" }, + { icon: Sparkles, label: "Personalisation" }, + { icon: Bell, label: "Notifications" }, + { icon: LockKeyhole, label: "Security" }, + { icon: CircleUserRound, label: "Account", active: true }, + { icon: Keyboard, label: "Keyboard" }, + { + icon: HelpCircle, + label: "Help & About", + onClick: () => { + onClose(); + onOpenGuide(); + }, + }, ]; + const closeButton = ( + + ); + return ( -
-
-
- -

Appearance

-
-

Choose the workspace theme. Saved to this browser.

-
- {themeOptions.map((option) => { - const active = option.value === theme; +
+ {closeButton} +
-
+ + -
-
- -

Account

-
-
- - {identity.initials} +
+
+
+

+ Refined settings +

+

+ Account & app +

+
+ + Clinician account - - - {identity.displayName} - - - - {identity.detail} +
+ +
+
+ + {identity.initials} + {identity.signedIn ? ( + + ) : null} - +
+

+ {identity.displayName} +

+

+ Consultant psychiatrist, Western Australia +

+
+
+ + +
+
+
+ + + +
+
+ +
+ + +
- {identity.signedIn ? ( + +
+
+ {settingSections.map((section) => ( +
+

{section.title}

+
+ {section.rows.map((row) => ( + + ))} + {section.title === "Account" && identity.signedIn ? ( + { + onSignOut(); + onClose(); + }} + actionLabel="Sign out" + /> + ) : null} +
+
+ ))} +
- ) : ( -

- Sign in from the search header to access private documents and saved reviews. -

- )} -
- - +
+
); } -function toolMatchesQuery(tool: ToolItem, query: string) { - const normalized = query.trim().toLowerCase(); - if (!normalized) return true; - return [tool.title, tool.description, tool.category, tool.status, tool.id] - .join(" ") - .toLowerCase() - .includes(normalized); -} - -function toolStatusLabel(tool: ToolItem) { - if (tool.status === "coming-soon") return "Soon"; - if (tool.status === "offline") return "Paused"; - if (tool.status === "beta") return "Preview"; - return "Ready"; -} - -const TOOL_ICON_MAP: Record> = { - Brain, - ClipboardList, - Search, - FileImage, - FileText, - HeartHandshake, - Network, - Pill, - UploadCloud, - BookOpen, - Quote, - ShieldAlert, - Target, - ClipboardCheck, - ListChecks, - Sparkles, - Clipboard: ClipboardCheck, - ExternalLink, -}; - -function getToolIcon(iconName: string) { - return TOOL_ICON_MAP[iconName] || Wrench; -} - -function cleanToolHrefLabel(href: string) { - try { - if (href.startsWith("/")) return href; - const url = new URL(href); - return url.host; - } catch { - return href; - } +function SettingsChip({ label }: { label: string }) { + return ( + + {label} + + ); } -function ToolsHub({ query, onClearQuery }: { query: string; onClearQuery: () => void }) { - const normalizedQuery = query.trim(); - const visibleTools = toolCatalog.filter((tool) => toolMatchesQuery(tool, normalizedQuery)); - const onlineTools = toolCatalog.filter((tool) => tool.status === "online" || tool.status === "beta").length; - const internalTools = toolCatalog.filter((tool) => tool.target === "internal").length; - const activeFilterCount = normalizedQuery ? 1 : 0; - +function SettingsSummaryTile({ + icon: Icon, + label, + value, + emphasized = false, +}: { + icon: typeof UserRound; + label: string; + value: string; + emphasized?: boolean; +}) { return ( -
-
-
- - - -
-

Tools

-

- Search the existing clinical applications registry and launch the right workflow without leaving the - dashboard shell. -

-
-
- -
- {[ - { label: "Tools", value: toolCatalog.length, icon: Wrench }, - { label: "Ready", value: onlineTools, icon: CheckCircle2 }, - { label: "In-app", value: internalTools, icon: LayoutList }, - ].map((stat) => { - const Icon = stat.icon; - return ( -
-
- - {stat.label} -
-

- {stat.value} -

-
- ); - })} -
-
- -
- - Showing {visibleTools.length} of {toolCatalog.length} tools - -
- {activeFilterCount > 0 && ( - +
+
+ - - Full launcher - -
+ > + + + + {label} + {value} +
+
+ ); +} - {visibleTools.length === 0 ? ( - - ) : ( -
- {visibleTools.map((tool) => { - const disabled = tool.status === "offline" || tool.status === "coming-soon"; - const launchLabel = `Launch ${tool.title}`; - const launchClassName = cn( - "inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-3 text-sm font-bold shadow-[var(--shadow-inset)] transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", - disabled - ? "border border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-soft)]" - : "bg-[color:var(--clinical-chat-teal)] text-white hover:bg-[color:var(--clinical-chat-teal-strong)]", - ); - const ToolIcon = getToolIcon(tool.icon); - const isOnline = tool.status === "online" || tool.status === "beta"; - const statusColorClass = - tool.status === "online" - ? "bg-emerald-500 shadow-[0_0_8px_#10b981]" - : tool.status === "beta" - ? "bg-amber-500 shadow-[0_0_8px_#f59e0b]" - : "bg-slate-400"; +function SettingsRow({ + icon: Icon, + label, + value, + active = false, + onClick, + actionLabel, +}: { + icon: typeof UserRound; + label: string; + value: string; + active?: boolean; + onClick?: () => void; + actionLabel?: string; +}) { + const content = ( + <> + + + + + {label} + {value ? ( + + {value} + + ) : null} + + + + ); - return ( -
-
- - - - - {toolStatusLabel(tool)} - -
+ const className = + "flex min-h-12 w-full items-center gap-3 border-b border-[color:var(--border)] px-3 text-left last:border-b-0 transition hover:bg-[color:var(--surface)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] sm:px-0 sm:hover:bg-transparent"; + const testId = `settings-row-${label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "")}`; -
-

{tool.title}

-

- {tool.description} -

-
- {tool.category} - - {tool.target === "internal" ? "In-app" : "Connected"} - -
-
- - - {cleanToolHrefLabel(tool.href)} - -
-
+ if (onClick) { + return ( + + ); + } - {disabled ? ( - {tool.disabledHint ?? "Unavailable"} - ) : tool.target === "internal" ? ( - - Launch - - - ) : ( - - Launch - - - )} -
- ); - })} -
- )} + return ( +
+ {content}
); } +function ToolsHub({ query, onQueryChange }: { query: string; onQueryChange: (nextQuery: string) => void }) { + return ; +} + type MobileSectionFabItem = { label: string; description: string; @@ -6172,6 +6365,7 @@ function MobileSectionFab({ const [open, setOpen] = useState(false); const [active, setActive] = useState(false); const buttonRef = useRef(null); + const panelRef = useRef(null); const panelId = "mobile-section-fab-menu"; const labelId = "mobile-section-fab-label"; const activeItem = items.find((item) => item.href === activeHash) ?? items[0]; @@ -6184,20 +6378,14 @@ function MobileSectionFab({ window.requestAnimationFrame(() => buttonRef.current?.focus()); } }, []); + const dismissMobileSectionMenu = useCallback(() => closeMenu(), [closeMenu]); - useEffect(() => { - if (!open) return; - - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - event.preventDefault(); - closeMenu(); - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [closeMenu, open]); + useDismissableLayer({ + enabled: open, + refs: [buttonRef, panelRef], + restoreFocusRef: buttonRef, + onDismiss: dismissMobileSectionMenu, + }); useEffect(() => { const mediaQuery = window.matchMedia(mobileSectionFabMediaQuery); @@ -6274,6 +6462,7 @@ function MobileSectionFab({
setGuideOpen(true), []); + const closeDashboardTransientSurfaces = useCallback( + (except?: "guide" | "settings" | "mobileSidebar" | "documents" | "upload") => { + if (except !== "guide") setGuideOpen(false); + if (except !== "settings") setSettingsOpen(false); + if (except !== "mobileSidebar") setMobileSidebarOpen(false); + if (except !== "documents") setDocumentsDrawerOpen(false); + if (except !== "upload") setUploadDrawerOpen(false); + }, + [], + ); + const openGuide = useCallback(() => { + closeDashboardTransientSurfaces("guide"); + setGuideOpen(true); + }, [closeDashboardTransientSurfaces]); const closeGuide = useCallback(() => setGuideOpen(false), []); - const openSettings = useCallback(() => setSettingsOpen(true), []); + const openSettings = useCallback(() => { + closeDashboardTransientSurfaces("settings"); + setSettingsOpen(true); + }, [closeDashboardTransientSurfaces]); const closeSettings = useCallback(() => setSettingsOpen(false), []); const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); const prefetchApplications = useCallback(() => { - router.prefetch("/applications"); - void import("@/components/applications-launcher-page"); + router.prefetch("/?mode=tools"); }, [router]); - const openLibraryHealthTarget = useCallback((target: LibraryHealthTarget) => { - const targetId = - target === "documents" - ? "dashboard-documents-drawer" - : target === "setup" - ? "dashboard-setup-section" - : "dashboard-indexing-section"; - - if (target === "documents") { - setDocumentDrawerStatusFilter("indexed"); - setDocumentsDrawerMode("admin"); - setDocumentsDrawerOpen(true); - } else if (target === "indexing") { - setUploadMobileTab("jobs"); - setIndexingMonitorFilter("active"); - setUploadDrawerOpen(true); - } else if (target === "failures") { - setUploadMobileTab("jobs"); - setIndexingMonitorFilter("failed"); - setUploadDrawerOpen(true); - } else { - setUploadMobileTab("setup"); - setIndexingMonitorFilter("all"); - setUploadDrawerOpen(true); - } + const openLibraryHealthTarget = useCallback( + (target: LibraryHealthTarget) => { + const targetId = + target === "documents" + ? "dashboard-documents-drawer" + : target === "setup" + ? "dashboard-setup-section" + : "dashboard-indexing-section"; + + if (target === "documents") { + closeDashboardTransientSurfaces("documents"); + setDocumentDrawerStatusFilter("indexed"); + setDocumentsDrawerMode("admin"); + setDocumentsDrawerOpen(true); + } else if (target === "indexing") { + closeDashboardTransientSurfaces("upload"); + setUploadMobileTab("jobs"); + setIndexingMonitorFilter("active"); + setUploadDrawerOpen(true); + } else if (target === "failures") { + closeDashboardTransientSurfaces("upload"); + setUploadMobileTab("jobs"); + setIndexingMonitorFilter("failed"); + setUploadDrawerOpen(true); + } else { + closeDashboardTransientSurfaces("upload"); + setUploadMobileTab("setup"); + setIndexingMonitorFilter("all"); + setUploadDrawerOpen(true); + } - window.setTimeout(() => { - document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth", block: "start" }); - }, 0); - }, []); + window.setTimeout(() => { + document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 0); + }, + [closeDashboardTransientSurfaces], + ); useEffect(() => { const timeoutId = window.setTimeout(prefetchApplications, 250); @@ -7644,6 +7855,7 @@ export function ClinicalDashboard({ } function openDocumentsDrawer(mode: DocumentDrawerMode) { + closeDashboardTransientSurfaces("documents"); setSearchMode("documents"); setDocumentDrawerStatusFilter("indexed"); setDocumentsDrawerMode(mode); @@ -7674,6 +7886,7 @@ export function ClinicalDashboard({ }); return; } + closeDashboardTransientSurfaces("upload"); setSearchMode("documents"); setDocumentsDrawerMode("admin"); setUploadDrawerOpen(true); @@ -7687,6 +7900,7 @@ export function ClinicalDashboard({ } function openEvidenceDrawer() { + closeDashboardTransientSurfaces(); const drawer = document.getElementById("answer-evidence-drawer") as HTMLDetailsElement | null; if (!drawer) { setActionNotice({ @@ -7895,7 +8109,7 @@ export function ClinicalDashboard({ activeModeResultKind === "favourites" ? favouritePrototypeCount : activeModeResultKind === "tools" - ? toolCatalog.length + ? applicationsLauncherItemCount : activeModeResultKind === "documents" ? documentMatches.length : null, @@ -8066,6 +8280,7 @@ export function ClinicalDashboard({ collapsed={sidebarCollapsed} recentQueries={recentQueries} identity={sidebarIdentity} + activeMode={searchMode} onCollapsedChange={setSidebarCollapsed} onNewChat={startNewChat} onPickRecent={pickRecentQuery} @@ -8096,8 +8311,16 @@ export function ClinicalDashboard({ onToggleScope={toggleDocumentScope} onOpenUpload={openUploadDrawer} onOpenEvidence={openEvidenceDrawer} + onOpenRecentDocuments={openRecentDocuments} + onOpenLibrary={openSourceLibrary} + onOpenSourcePdf={openSourcePdfBrowser} onNewChat={startNewChat} - onOpenMobileSidebar={() => setMobileSidebarOpen(true)} + onOpenMobileSidebar={() => { + closeDashboardTransientSurfaces("mobileSidebar"); + setMobileSidebarOpen(true); + }} + onOpenSettings={openSettings} + identity={sidebarIdentity} onToggleTheme={toggleTheme} queryModeOptions={clinicalQueryModeOptions} queryInputRef={composerInputRef} @@ -8185,7 +8408,7 @@ export function ClinicalDashboard({ } /> ) : activeModeResultKind === "tools" ? ( - setQuery("")} /> + ) : activeModeResultKind === "documents" ? ( searchMode === "prescribing" ? ( (null); const timeoutRef = useRef(null); + const rootRef = useRef(null); + const triggerRef = useRef(null); const clearNotice = useCallback(() => { if (timeoutRef.current) { @@ -56,8 +59,17 @@ export function DashboardFloatingFab() { // component is gone (leaked timer / stray setState). useEffect(() => clearNotice, [clearNotice]); + const dismissQuickActions = useCallback(() => setOpen(false), []); + + useDismissableLayer({ + enabled: open, + refs: [rootRef], + restoreFocusRef: triggerRef, + onDismiss: dismissQuickActions, + }); + return ( -
+
setOpen(false)} className={cn(floatingControl, "h-9 min-h-9 px-3 text-xs", !open && "hidden")} > - - Applications + + Tools {copyNotice && (

@@ -106,6 +118,7 @@ export function DashboardFloatingFab() { )}

-

Related apps

+

{copy.relatedHeading}

{related.map((relatedApp) => { const Icon = relatedApp.icon; @@ -825,14 +892,29 @@ function ApplicationsHeader({ ); } -export function ApplicationsLauncherPage() { - const [query, setQuery] = useState(""); +type ApplicationsLauncherWorkspaceProps = { + variant?: LauncherVariant; + query?: string; + onQueryChange?: (query: string) => void; + className?: string; +}; + +export function ApplicationsLauncherWorkspace({ + variant = "standalone", + query: controlledQuery, + onQueryChange, + className, +}: ApplicationsLauncherWorkspaceProps) { + const [uncontrolledQuery, setUncontrolledQuery] = useState(""); const [activeFilter, setActiveFilter] = useState<(typeof filterOptions)[number]["id"]>("all"); const [selectedId, setSelectedId] = useState("clinical-kb-search"); const [pinnedIds, setPinnedIds] = useState(seedPinnedIds); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [modeMenuOpen, setModeMenuOpen] = useState(false); const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + const isDashboardTools = variant === "dashboard-tools"; + const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; + const query = controlledQuery ?? uncontrolledQuery; const normalizedQuery = query.trim().toLowerCase(); const pinnedApps = pinnedIds.map(appById); @@ -865,33 +947,61 @@ export function ApplicationsLauncherPage() { } } + function updateQuery(nextQuery: string) { + if (controlledQuery === undefined) { + setUncontrolledQuery(nextQuery); + } + onQueryChange?.(nextQuery); + } + function submitFooterSearch(event: FormEvent) { event.preventDefault(); const firstMatch = filteredApps[0]; if (firstMatch) selectApplication(firstMatch.id); } - return ( -
- - + const workspace = ( + <> + {isDashboardTools ? ( +
+
+ + + +
+

+ {copy.heading} +

+

+ {copy.description} +

+
+
-
+
+ + {normalizedQuery ? ( + + ) : null} +
+
+ ) : (

- Applications + {copy.heading}

- Open the clinical applications and connected workflows you use for assessment, formulation, prescribing, - documents, and saved workflows. + {copy.description}

@@ -900,17 +1010,17 @@ export function ApplicationsLauncherPage() { @@ -921,112 +1031,129 @@ export function ApplicationsLauncherPage() { type="submit" disabled={filteredApps.length === 0} className={chatSendButton} - aria-label="Open selected application" + aria-label={copy.openSelectedAriaLabel} > Open
+ )} -
-
- +
+
+ -
-
- -

All applications

-
+
+
+ +

{copy.allSectionLabel}

+
-
- Application - Last used - Status - Action - -
+
+ {copy.allColumnLabel} + Last used + Status + Action + +
-
- {filteredApps.map((app) => ( - - ))} + {filteredApps.length === 0 ? ( +
+

{copy.emptyTitle}

+

{copy.emptyBody}

+ ) : ( + <> +
+ {filteredApps.map((app) => ( + + ))} +
-
- {filteredApps.map((app) => ( - - ))} -
+
+ {filteredApps.map((app) => ( + + ))} +
+ + )} -

- Showing {filteredApps.length > 0 ? "1" : "0"} to {filteredApps.length} of {launcherApps.length}{" "} - applications -

-
- -
-
-

Recent activity

- -
-
- {recentActivity.slice(0, 3).map((item) => { - const Icon = item.icon; - return ( -
+ +
+
+

Recent activity

+ +
+
+ {recentActivity.slice(0, 3).map((item) => { + const Icon = item.icon; + return ( + - ); - })} -
-
-
+ {item.date} + + + ); + })} +
+
+
-
- -
+
+
-
+
setMobileDetailOpen(false)} labelledBy="selected-application-sheet-heading" - closeLabel="Close selected application" + closeLabel={copy.closeSelectedLabel} contentClassName="lg:hidden rounded-t-[1.75rem] bg-[color:var(--surface-lux)]" bodyClassName="px-5 pb-6 pt-4 sm:px-5" portal @@ -1037,11 +1164,42 @@ export function ApplicationsLauncherPage() { onTogglePin={togglePin} onClose={() => setMobileDetailOpen(false)} headingId="selected-application-sheet-heading" + copy={copy} testId="selected-application-sheet-panel" variant="sheet" /> + + ); + if (isDashboardTools) { + return ( +
+ {workspace} +
+ ); + } + + return ( +
+ + + +
+ {workspace} +
); } + +export function ApplicationsLauncherPage() { + return ; +} diff --git a/src/components/clinical-dashboard/dashboard-shell.tsx b/src/components/clinical-dashboard/dashboard-shell.tsx index 5a9a573858..6dc65e9c73 100644 --- a/src/components/clinical-dashboard/dashboard-shell.tsx +++ b/src/components/clinical-dashboard/dashboard-shell.tsx @@ -80,6 +80,10 @@ export function UtilityDrawer({ sheetTitleAccessory, sheetDescriptionContent, sheetHeaderActions, + sheetHeaderClassName, + sheetTitleClassName, + sheetCloseButtonClassName, + sheetChildrenClassName, sheetContentClassName, sheetContentStyle, sheetBodyClassName, @@ -100,6 +104,10 @@ export function UtilityDrawer({ sheetTitleAccessory?: ReactNode; sheetDescriptionContent?: ReactNode; sheetHeaderActions?: ReactNode; + sheetHeaderClassName?: string; + sheetTitleClassName?: string; + sheetCloseButtonClassName?: string; + sheetChildrenClassName?: string; sheetContentClassName?: string; sheetContentStyle?: CSSProperties; sheetBodyClassName?: string; @@ -203,13 +211,16 @@ export function UtilityDrawer({ titleAccessory={sheetTitleAccessory} descriptionContent={sheetDescriptionContent} headerActions={sheetHeaderActions} + headerClassName={sheetHeaderClassName} + titleClassName={sheetTitleClassName} + closeButtonClassName={sheetCloseButtonClassName} contentClassName={sheetContentClassName} contentStyle={sheetContentStyle} bodyClassName={sheetBodyClassName} returnFocusRef={mobileTriggerRef} portal > -
{children}
+
{children}
); diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index 8aa0a0742e..48bc60c18f 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -1,7 +1,8 @@ "use client"; import { ArrowUpDown, ChevronDown, Filter, Folder, FolderInput, Heart, Plus, Search, X } from "lucide-react"; -import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { cn, floatingControl, iconTilePremium, panelSubtle, primaryControl } from "@/components/ui-primitives"; import { favouriteItems, @@ -103,30 +104,12 @@ export function FavouritesHub({ } } - useEffect(() => { - if (!tabMenuOpen) return undefined; - - function handlePointerDown(event: PointerEvent) { - const target = event.target; - if (!(target instanceof Node)) return; - if (!tabMenuRef.current?.contains(target)) setTabMenuOpen(false); - } - - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - event.preventDefault(); - setTabMenuOpen(false); - window.requestAnimationFrame(() => tabButtonRef.current?.focus()); - } - } - - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [tabMenuOpen]); + useDismissableLayer({ + enabled: tabMenuOpen, + refs: [tabMenuRef], + restoreFocusRef: tabButtonRef, + onDismiss: () => setTabMenuOpen(false), + }); return (
diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 10390b6727..c8721753f7 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -116,6 +116,8 @@ export function GlobalMockupSearchShell({ children }: { children: ReactNode }) { onOpenEvidence={() => navigateToMode("evidence", { focus: true })} onNewChat={startNewChat} onOpenMobileSidebar={() => setMobileMenuOpen(true)} + onOpenSettings={() => setMobileMenuOpen(true)} + identity={{ displayName: "Guest", initials: "G", detail: "Not signed in", signedIn: false }} onToggleTheme={toggleTheme} queryModeOptions={mockupQueryModeOptions} scopeVariant="placeholder" diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 2f2f773824..3863475276 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -18,7 +18,6 @@ import { CheckCircle2, ChevronDown, FileText, - Filter, Globe2, Heart, ListChecks, @@ -39,6 +38,14 @@ import { } from "lucide-react"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; +import { useDismissableLayer } from "@/components/use-dismissable-layer"; +import { + ModeActionPopup, + modeActionItemsFor, + type ModeActionId, + type ModeActionItem, + type ModeActionSetId, +} from "@/components/clinical-dashboard/mode-action-popup"; import { cn, chatComposerIconButton, @@ -75,6 +82,25 @@ const appModeIcons: Record = { tools: Wrench, }; +const medicationModeActionItems: readonly ModeActionItem[] = [ + { + id: "medication-dose", + label: "Dose", + description: "Check dosing and thresholds", + icon: CalendarDays, + primary: true, + }, + { id: "medication-safety", label: "Safety", description: "Contraindications and cautions", icon: ShieldCheck }, + { + id: "medication-monitoring", + label: "Monitoring", + shortLabel: "Monitor", + description: "Baseline and ongoing checks", + icon: Activity, + }, + { id: "medication-access", label: "Access", description: "Documentation and eligibility", icon: Lock }, +]; + function splitFilterText(value: string) { return value .split(",") @@ -98,6 +124,13 @@ function documentScopeMeta(document: ClinicalDocument) { return `${fileName} · ${document.page_count ?? "?"} pages`; } +type HeaderIdentity = { + displayName: string; + initials: string; + detail: string; + signedIn: boolean; +}; + export function MasterSearchHeader({ documents, documentTotal, @@ -120,8 +153,13 @@ export function MasterSearchHeader({ onScopeOpenChange, onOpenUpload, onOpenEvidence, + onOpenRecentDocuments, + onOpenLibrary, + onOpenSourcePdf, onNewChat, onOpenMobileSidebar, + onOpenSettings, + identity, onToggleTheme, queryModeOptions, scopeVariant = "full", @@ -150,8 +188,13 @@ export function MasterSearchHeader({ onScopeOpenChange?: (open: boolean) => void; onOpenUpload?: () => void; onOpenEvidence?: () => void; + onOpenRecentDocuments?: () => void; + onOpenLibrary?: () => void; + onOpenSourcePdf?: () => void; onNewChat?: () => void; onOpenMobileSidebar?: () => void; + onOpenSettings: () => void; + identity: HeaderIdentity; onToggleTheme: () => void; queryModeOptions: Array<{ value: ClinicalQueryMode; label: string }>; scopeVariant?: "full" | "placeholder"; @@ -174,12 +217,9 @@ export function MasterSearchHeader({ const [scopeFilter, setScopeFilter] = useState(""); const [scopeOpen, setScopeOpen] = useState(false); const [scopeSheetOpen, setScopeSheetOpen] = useState(false); - const [dailyActionsOpen, setDailyActionsOpen] = useState(false); + const [actionMenuOpen, setActionMenuOpen] = useState(false); const [modeMenuOpen, setModeMenuOpen] = useState(false); const [usesScopeSheet, setUsesScopeSheet] = useState(false); - const dailyActionButtonRef = useRef(null); - const dailyActionsMenuRef = useRef(null); - const firstDailyActionRef = useRef(null); const modeMenuRef = useRef(null); const modeButtonRef = useRef(null); const modeOptionRefs = useRef>([]); @@ -220,81 +260,105 @@ export function MasterSearchHeader({ const submitLabel = trimmedQuery ? selectedSearch.submitBusyLabel : selectedSearch.submitIdleLabel; const queryPlaceholder = selectedSearch.placeholder; const SelectedAppModeIcon = appModeIcons[selectedAppMode.id]; - const dailyActions = - searchMode === "prescribing" - ? ([ - { label: "Dose", icon: CalendarDays }, - { label: "Safety", icon: ShieldCheck }, - { label: "Monitoring", icon: Activity }, - { label: "Access", icon: Lock }, - ] as const) - : ([ - { label: "Search library", icon: Search }, - { label: "Add document", icon: FileText }, - { label: "Scope", icon: Filter }, - { label: "Evidence", icon: ListChecks }, - { label: "Tools", icon: Wrench }, - ] as const); - const dailyActionsTitle = searchMode === "prescribing" ? "Medication checks" : "Daily actions"; - const dailyActionsButtonLabel = searchMode === "prescribing" ? "Open medication checks" : "Open daily actions"; - const dailyActionsDescription = - searchMode === "prescribing" - ? "Choose a dose, safety, monitoring, or access focus." - : "Search, add, scope, evidence, or tools."; + const actionMenuSetId: ModeActionSetId = + searchMode === "documents" || searchMode === "evidence" ? "documents" : searchMode === "tools" ? "tools" : "answer"; + const actionMenuItems = + searchMode === "prescribing" ? medicationModeActionItems : modeActionItemsFor(actionMenuSetId); + const actionMenuTitle = selectedAppMode.label; + const actionMenuButtonLabel = `Open ${selectedAppMode.label.toLowerCase()} options`; function currentUsesScopeSheet() { return window.matchMedia(mobileSheetMediaQuery).matches; } - function runDailyAction(label: (typeof dailyActions)[number]["label"]) { - if (searchMode === "prescribing") { + function openScopePicker() { + setActionMenuOpen(false); + setModeMenuOpen(false); + const nextUsesScopeSheet = currentUsesScopeSheet(); + setUsesScopeSheet(nextUsesScopeSheet); + if (nextUsesScopeSheet) { + setScopeSheetOpen(true); + } else { + setScopeOpen(true); + onScopeOpenChange?.(true); + window.requestAnimationFrame(() => scopeFilterInputRef.current?.focus()); + } + } + + function runModeAction(actionId: ModeActionId) { + if (actionId === "medication-dose") { const medicationQuery = trimmedQuery || "acamprosate renal dose"; - if (label === "Dose") { - onQueryModeChange("dose_threshold_lookup"); - onQueryChange(medicationQuery); - return; - } - if (label === "Safety") { - onQueryModeChange("contraindications_cautions"); - onQueryChange(trimmedQuery || "acamprosate contraindications"); - return; - } - if (label === "Monitoring") { - onQueryModeChange("monitoring_schedule"); - onQueryChange(trimmedQuery || "acamprosate monitoring"); - return; - } - if (label === "Access") { - onQueryModeChange("required_documentation"); - onQueryChange(trimmedQuery || "acamprosate PBS access"); - return; - } + onQueryModeChange("dose_threshold_lookup"); + onQueryChange(medicationQuery); + return; + } + if (actionId === "medication-safety") { + onQueryModeChange("contraindications_cautions"); + onQueryChange(trimmedQuery || "acamprosate contraindications"); + return; } - if (label === "Search library") { + if (actionId === "medication-monitoring") { + onQueryModeChange("monitoring_schedule"); + onQueryChange(trimmedQuery || "acamprosate monitoring"); + return; + } + if (actionId === "medication-access") { + onQueryModeChange("required_documentation"); + onQueryChange(trimmedQuery || "acamprosate PBS access"); + return; + } + + if (actionId === "documents-search" || actionId === "answer-documents") { onSearchModeChange("documents"); return; } - if (label === "Add document") { + if (actionId === "documents-upload") { onOpenUpload?.(); return; } - if (label === "Scope") { - const nextUsesScopeSheet = currentUsesScopeSheet(); - setUsesScopeSheet(nextUsesScopeSheet); - if (nextUsesScopeSheet) { - setScopeSheetOpen(true); - } else { - setScopeOpen(true); - onScopeOpenChange?.(true); - window.requestAnimationFrame(() => scopeFilterInputRef.current?.focus()); - } + if (actionId === "documents-scope") { + openScopePicker(); return; } - if (label === "Evidence") { + if (actionId === "answer-evidence") { onOpenEvidence?.(); return; } - onSearchModeChange("tools"); + if (actionId === "documents-tables") { + onSearchModeChange("documents"); + onQueryChange(trimmedQuery || "table evidence"); + return; + } + if (actionId === "documents-recent") { + onSearchModeChange("documents"); + onOpenRecentDocuments?.(); + return; + } + if (actionId === "documents-status" || actionId === "documents-collections") { + onSearchModeChange("documents"); + onOpenLibrary?.(); + return; + } + if (actionId === "documents-viewer") { + onSearchModeChange("documents"); + onOpenSourcePdf?.(); + return; + } + if (actionId === "answer-new" || actionId === "tools-new") { + onNewChat?.(); + return; + } + if (actionId === "answer-clinical" || actionId === "favourites-answer") { + onSearchModeChange("answer"); + return; + } + if (actionId === "tools-browse" || actionId === "favourites-tools") { + onSearchModeChange("tools"); + return; + } + if (actionId === "tools-favourites" || actionId === "favourites-browse") { + onSearchModeChange("favourites"); + } } function selectAppMode(mode: (typeof appModeDefinitions)[number]) { @@ -317,6 +381,8 @@ export function MasterSearchHeader({ } function openModeMenuWithFocus(index: number) { + setActionMenuOpen(false); + closeScope(false); setModeMenuOpen(true); window.requestAnimationFrame(() => focusModeOption(index)); } @@ -350,6 +416,7 @@ export function MasterSearchHeader({ window.requestAnimationFrame(() => modeButtonRef.current?.focus()); } } + const collectionOptions = useMemo(() => { const values = new Set(); for (const document of documents) { @@ -388,84 +455,28 @@ export function MasterSearchHeader({ onScopeOpenChange?.(scopeOpen || scopeSheetOpen); }, [onScopeOpenChange, scopeOpen, scopeSheetOpen]); - useEffect(() => { - if (!dailyActionsOpen || usesScopeSheet) return undefined; - - function handlePointerDown(event: PointerEvent) { - const target = event.target; - if (!(target instanceof Node)) return; - if (!dailyActionsMenuRef.current?.contains(target)) setDailyActionsOpen(false); - } - - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - event.preventDefault(); - setDailyActionsOpen(false); - window.requestAnimationFrame(() => dailyActionButtonRef.current?.focus()); - } - } - - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [dailyActionsOpen, usesScopeSheet]); - - useEffect(() => { - if (!modeMenuOpen) return undefined; - - function handlePointerDown(event: PointerEvent) { - const target = event.target; - if (!(target instanceof Node)) return; - if (!modeMenuRef.current?.contains(target)) setModeMenuOpen(false); - } - - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - event.preventDefault(); - setModeMenuOpen(false); - window.requestAnimationFrame(() => modeButtonRef.current?.focus()); - } - } - - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [modeMenuOpen]); - - useEffect(() => { - const details = scopeDetailsRef.current; - if (!scopeOpen || !details?.open) return undefined; - - function handlePointerDown(event: PointerEvent) { - const target = event.target; - if (!(target instanceof Node)) return; - if (!scopeDetailsRef.current?.contains(target)) closeScope(false); - } + const dismissModeMenu = useCallback(() => setModeMenuOpen(false), []); + function dismissScope(reason: "outside" | "escape") { + closeScope(reason === "escape"); + } - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - event.preventDefault(); - closeScope(true); - } - } + useDismissableLayer({ + enabled: modeMenuOpen, + refs: [modeMenuRef], + restoreFocusRef: modeButtonRef, + onDismiss: dismissModeMenu, + }); - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [closeScope, scopeOpen]); + useDismissableLayer({ + enabled: scopeOpen, + refs: [scopeDetailsRef], + restoreFocusRef: scopeSummaryRef, + onDismiss: dismissScope, + }); function submit(event: FormEvent) { event.preventDefault(); - setDailyActionsOpen(false); + setActionMenuOpen(false); onAsk(); } @@ -702,7 +713,11 @@ export function MasterSearchHeader({ - - AK - - +
@@ -896,49 +928,20 @@ export function MasterSearchHeader({ "fixed inset-x-3 bottom-3 z-40 mx-auto max-w-3xl sm:bottom-4 lg:left-[calc(var(--clinical-sidebar-width,20rem)+2rem)] lg:right-8 lg:max-w-4xl", )} > -
- - {dailyActionsOpen && !usesScopeSheet ? ( -
- {dailyActions.map((item) => { - const Icon = item.icon; - return ( - - ); - })} -
- ) : null} -
+ { + setUsesScopeSheet(currentUsesScopeSheet()); + setModeMenuOpen(false); + closeScope(false); + }} + onAction={runModeAction} + />