diff --git a/.gitignore b/.gitignore index c66b66fc31..f7ed60964d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,12 @@ /output/ /test-results/ /playwright-report/ +/playwright-cli/ +/.playwright-cli/ +/playwright/.auth/ +/.codex/ +/.agents/ +/skills-lock.json # next.js /.next/ diff --git a/AGENTS.md b/AGENTS.md index 6b9fd5fac0..35b5468330 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,7 @@ After setup: - Never attach to a local server unless `/api/local-project-id` confirms it is this project. - Do not kill or modify other projects' local servers. If the stable project port is busy, let `npm run ensure` choose the next safe project URL. - Do not run a permanent watcher. Only start or verify the server when the current chat task needs the app or the user asks to run it. + @@ -111,6 +112,7 @@ After setup: - For clinical ingestion, answer generation, source governance, privacy, production-readiness, or environment changes, run the smallest relevant domain check plus `npm run check:production-readiness`. - For pull requests that touch ingestion, answer generation, search/ranking, source rendering, document access, privacy, production env, or clinical output, complete the clinical governance preflight in `.github/pull_request_template.md`. - Track known verification debts and staged process improvements in `docs/process-hardening.md` instead of relying on chat-only memory. + @@ -121,6 +123,7 @@ After setup: - Expected project ref: `sjrfecxgysukkwxsowpy`. - Older unused project ref `qjgitjyhxrwxsrydablr` belongs to `Database`; treat it as stale and do not use it. - Run `npm run check:supabase-project` after changing Supabase env values. + diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000000..9aa6535f2d --- /dev/null +++ b/deno.lock @@ -0,0 +1,267 @@ +{ + "version": "5", + "specifiers": { + "jsr:@supabase/functions-js@*": "2.108.2", + "npm:openai@^4.52.5": "4.104.0", + "npm:postgres@3.4.7": "3.4.7" + }, + "jsr": { + "@supabase/functions-js@2.108.2": { + "integrity": "39665d68e1cb721b0714ed1f55c075fba16f8d992672b51458660b3c1ec77c8f", + "dependencies": [ + "npm:openai" + ] + } + }, + "npm": { + "@types/node-fetch@2.6.13": { + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dependencies": [ + "@types/node", + "form-data" + ] + }, + "@types/node@18.19.130": { + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dependencies": [ + "undici-types" + ] + }, + "abort-controller@3.0.0": { + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": [ + "event-target-shim" + ] + }, + "agentkeepalive@4.6.0": { + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dependencies": [ + "humanize-ms" + ] + }, + "asynckit@0.4.0": { + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "combined-stream@1.0.8": { + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": [ + "delayed-stream" + ] + }, + "delayed-stream@1.0.0": { + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.2": { + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": [ + "es-errors" + ] + }, + "es-set-tostringtag@2.1.0": { + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": [ + "es-errors", + "get-intrinsic", + "has-tostringtag", + "hasown" + ] + }, + "event-target-shim@5.0.1": { + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "form-data-encoder@1.7.2": { + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "form-data@4.0.6": { + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": [ + "asynckit", + "combined-stream", + "es-set-tostringtag", + "hasown", + "mime-types" + ] + }, + "formdata-node@4.4.1": { + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": [ + "node-domexception", + "web-streams-polyfill" + ] + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag@1.0.2": { + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": [ + "has-symbols" + ] + }, + "hasown@2.0.4": { + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": [ + "function-bind" + ] + }, + "humanize-ms@1.2.1": { + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": [ + "ms" + ] + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": [ + "mime-db" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node-domexception@1.0.0": { + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": true + }, + "node-fetch@2.7.0": { + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": [ + "whatwg-url" + ] + }, + "openai@4.104.0": { + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "dependencies": [ + "@types/node", + "@types/node-fetch", + "abort-controller", + "agentkeepalive", + "form-data-encoder", + "formdata-node", + "node-fetch" + ], + "bin": true + }, + "postgres@3.4.7": { + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==" + }, + "tr46@0.0.3": { + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "undici-types@5.26.5": { + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "web-streams-polyfill@4.0.0-beta.3": { + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==" + }, + "webidl-conversions@3.0.1": { + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url@5.0.0": { + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + } + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@next/env@16.2.9", + "npm:@supabase/supabase-js@^2.108.2", + "npm:@tailwindcss/postcss@^4.3.1", + "npm:@types/node@^24.13.2", + "npm:@types/pdf-parse@^1.1.5", + "npm:@types/pdfkit@~0.17.6", + "npm:@types/react-dom@^19.2.3", + "npm:@types/react@^19.2.17", + "npm:@vitest/coverage-v8@^4.1.9", + "npm:eslint-config-next@16.2.9", + "npm:eslint@^9.39.4", + "npm:exceljs@^4.4.0", + "npm:jszip@^3.10.1", + "npm:lucide-react@^1.21.0", + "npm:mammoth@^1.12.0", + "npm:next@16.2.9", + "npm:openai@^6.45.0", + "npm:pdf-parse@^2.4.5", + "npm:pdfjs-dist@^6.0.227", + "npm:pdfkit@~0.19.1", + "npm:playwright@^1.61.1", + "npm:postcss@^8.5.15", + "npm:postgres@3.4.9", + "npm:prettier@^3.9.0", + "npm:react-dom@19.2.7", + "npm:react@19.2.7", + "npm:tailwindcss@^4.3.1", + "npm:tesseract.js@7", + "npm:tsx@^4.22.4", + "npm:typescript@^6.0.3", + "npm:vitest@^4.1.8", + "npm:zod@^4.4.3" + ], + "overrides": { + "postcss": "^8.5.15", + "esbuild": "0.28.1", + "tmp": "^0.2.7", + "uuid": "^11.1.1" + } + } + } +} diff --git a/design-qa.md b/design-qa.md index ae54096bee..30bb65a4f3 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,4 +1,5 @@ **Source Visual Truth** + - Main answer state target: `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-1cece938-9dd9-40cf-8fa1-eaade3c0d378.png` - Evidence/source behaviour target: `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-7728dc87-8e3a-459a-97de-5ae9d103d5c7.png` - Navigation/documents/daily tools target: `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-9a48092e-9b52-44b1-ad68-7123224c4eed.png` @@ -6,6 +7,7 @@ - Phase scope: Phase 7 visual parity polish after Phase 6 hardening. Keep behavior stable while tightening canvas color, answer density, table width, document-search controls, and low-elevation surfaces against the supplied mockups. **Implementation Evidence** + - Mobile empty screenshot: `C:\Dev\Apps\Database\output\playwright\clinical-guide-phase-7\mobile-empty.png` - Mobile menu screenshot: `C:\Dev\Apps\Database\output\playwright\clinical-guide-phase-7\mobile-menu-open.png` - Mobile daily actions screenshot: `C:\Dev\Apps\Database\output\playwright\clinical-guide-phase-7\mobile-daily-actions.png` @@ -15,6 +17,7 @@ - Viewports: mobile 390x820, desktop 1280x900, light theme, controlled demo fixtures. **Findings** + - No P0/P1 findings remain for Phase 7. - Remaining visual parity gap is P2 polish only: the rendered answer state is still a little lower-contrast and more spacious than the mockup, but the shell, mode behavior, evidence layout, documents mode, and mobile menu interactions match the planned structure. - Mobile hamburger now opens an opaque left-side Clinical Guide sheet without clipped or half-animated capture states. @@ -25,6 +28,7 @@ - Documents mode is cleaner: default result cards no longer show the diagnostic tag facet rail or "No direct support" chips unless useful support metadata is present. **Patches Made In Phase 7** + - Shifted the main Clinical Guide shell back to a white clinical canvas instead of the heavier blue-grey page tint. - Narrowed the answer content column on active chat states to better match the mockup reading width. - Slightly tightened mobile key-monitoring typography and list line-height while preserving 44px interactive controls. @@ -33,6 +37,7 @@ - Kept source capsule, clinical notes, evidence rows, mobile sidebar, daily actions, and documents mode behavior unchanged. **Patches Retained From Phase 6** + - Removed left-sheet translation/fade issues from the shared `Sheet` placement and hid the bottom-sheet handle for left navigation sheets. - Moved mobile Daily actions out of the fixed composer form so it gets a proper viewport stacking context. - Upgraded shared answer/source/table action primitives to 44px touch targets on mobile while keeping compact desktop sizing. @@ -44,6 +49,7 @@ - Added smoke coverage for on-screen mobile sidebar placement, Daily actions sheet behavior, 44px targets, document diagnostic suppression, and evidence tab panels. **Verification** + - `npm run ensure` confirmed the app at `http://localhost:4298`. - `npx playwright test tests/ui-smoke.spec.ts --project=chromium --grep "dashboard loads without page overflow at small-mobile|demo answer flow reaches|document search mode lists" --reporter=line` passed. - `npx playwright test tests/ui-stress.spec.ts --project=chromium --grep "many documents and citations do not overflow at mobile|many documents and citations do not overflow at desktop" --reporter=line` passed. @@ -51,19 +57,232 @@ - `npm run verify:ui` passed: 28 Chromium Playwright tests. **Follow-up Polish** + - Consider adding a stable visual-regression snapshot once the current Clinical Guide parity pass is signed off. final result: passed --- +**Medication Prescribing Routed Polish** + +**Scope** + +- Built the approved medication mockup direction into the app flow. +- Kept medication home and search results inside the global Clinical Guide header and bottom composer. +- Moved the individual Acamprosate medication page to a standalone route without the global composer. + +**Routes** + +- Medication home/results: `http://localhost:4298/?mode=prescribing` +- Acamprosate sample page: `http://localhost:4298/medications/acamprosate` +- Medication index redirect: `http://localhost:4298/medications` + +**Screenshots** + +- Phone home: `C:\Dev\Apps\Database\output\playwright\medication-home-routed-phone.png` +- Desktop results: `C:\Dev\Apps\Database\output\playwright\medication-results-routed-desktop.png` +- Phone results: `C:\Dev\Apps\Database\output\playwright\medication-results-routed-phone.png` +- Desktop detail: `C:\Dev\Apps\Database\output\playwright\medication-detail-routed-desktop-v2.png` +- Phone detail: `C:\Dev\Apps\Database\output\playwright\medication-detail-routed-phone-v2.png` + +**Changes Made** + +- Wired Acamprosate result cards to `/medications/acamprosate`. +- Marked other sample medications as non-clickable pending records until real medication pages exist. +- Converted medication result filters into working stateful filters. +- Added a standalone Acamprosate route with compact local medication chrome, metadata, and no global search composer. +- Converted mobile detail pseudo-buttons into static clinical rows or real expandable disclosures. +- Added working phone section navigation for Summary, Dosing, Safety, and More. +- Tightened desktop detail cards to match the four-tile mockup at normal desktop width. + +**Browser QA** + +- `npm run ensure` confirmed the app at `http://localhost:4298`. +- Verified phone result card routes to `/medications/acamprosate`. +- Verified page title `Acamprosate | Clinical KB`. +- Verified no horizontal overflow at 390x920. +- Verified zero browser console errors during routed medication flow. +- `npm run lint -- --max-warnings=0 src/components/clinical-dashboard/medication-prescribing-workspace.tsx src/app/medications/page.tsx 'src/app/medications/[slug]/page.tsx'` passed. +- `npm run typecheck -- --pretty false` passed. +- `npm run verify:ui` passed: 33 Chromium Playwright tests. + +final result: passed + +--- + +**Medication Detail Toggle Polish** + +**Scope** + +- Further optimized the Acamprosate medication detail page at `http://localhost:4298/medications/acamprosate`. +- Focus: better use of toggles/disclosure, cleaner hierarchy, reduced secondary-content exposure, and stable phone tab behavior. + +**Evidence** + +- Before desktop: `C:\Dev\Apps\Database\output\playwright\medication-detail-opt-before-desktop.png` +- Before phone: `C:\Dev\Apps\Database\output\playwright\medication-detail-opt-before-phone.png` +- Final desktop: `C:\Dev\Apps\Database\output\playwright\medication-detail-opt-final-desktop.png` +- Final phone: `C:\Dev\Apps\Database\output\playwright\medication-detail-opt-final-phone.png` +- Full toggle state: `C:\Dev\Apps\Database\output\playwright\medication-detail-opt-full-toggle-desktop.png` +- Phone safety tab state: `C:\Dev\Apps\Database\output\playwright\medication-detail-opt-safety-tab-phone-final.png` + +**Design Issues Fixed** + +- Added a desktop `Core / Full` toggle so high-yield prescribing content is the default, while the full medication reference remains one click away. +- Moved lower-priority populations, risks, and PK detail into a collapsed disclosure in Core view. +- Made mobile section tabs stateful with `aria-pressed`, so Summary/Dosing/Safety/More now read as actual controls. +- Added a visibility guard to mobile tab scrolling so tall iPhone views do not jump or clip the title when the target section is already visible. +- Replaced heavy black interaction bullets with quiet teal clinical markers. + +**Verification** + +- `npm run lint -- --max-warnings=0 src/components/clinical-dashboard/medication-prescribing-workspace.tsx` passed. +- `npm run typecheck -- --pretty false` passed. +- Standalone browser assertion passed for medication results route, Acamprosate detail route, and `Core / Full` toggle behavior. +- `npx playwright test tests/ui-smoke.spec.ts --project=chromium --grep "prescribing workflow uses in-app medication routes" --reporter=line` passed. +- `npx playwright test tests/ui-smoke.spec.ts --project=chromium --grep "dashboard loads without page overflow at standard-mobile" --reporter=line` passed after the broad gate showed a retry marker there. +- `npm run verify:ui` was attempted; runtime check passed, but the full Chromium gate was interrupted before completion after unrelated baseline smoke tests and did not reach the medication route test in that run. + +final result: passed with full-gate limitation + +--- + +**Medication Prescribing Route Wiring Final Polish** + +**Scope** + +- Final routed implementation pass for the Medication app mode, medication home, medication search results, and Acamprosate detail route. +- Target: keep the approved medication mockup direction while making the in-app flow work from the global mode selector, sidebar app tiles, prompt chips, recent medication searches, result rows, and phone cards. + +**Evidence** + +- Medication home: `C:\Dev\Apps\Database\output\playwright\medication-home-final-desktop.png` +- Search results desktop: `C:\Dev\Apps\Database\output\playwright\medication-results-final-desktop.png` +- Search results phone: `C:\Dev\Apps\Database\output\playwright\medication-results-final-phone.png` +- Acamprosate detail desktop: `C:\Dev\Apps\Database\output\playwright\medication-detail-final-desktop.png` +- Acamprosate detail phone: `C:\Dev\Apps\Database\output\playwright\medication-detail-final-phone.png` + +**Design And Wiring Fixes** + +- Wired the top app mode label/href to `Medication` on `/?mode=prescribing` instead of the older prescribing mockup route. +- Routed sidebar and launcher medication entries into the in-app Medication mode. +- Prevented Medication mode from triggering document-search setup gating, so medication prompt clicks work even when document search setup is unavailable. +- Added query URL support for `/?mode=prescribing&q=...` and wired Acamprosate results to `/medications/acamprosate`. +- Made recent-query picks medication-aware when the active mode is Medication. +- Tightened the results header, reduced row noise, removed redundant pending badges, and made dose ceiling read as quiet metadata. +- Preserved the standalone medication detail page outside the global header/footer while keeping a compact medication-specific header and clinical summary layout. + +**Verification** + +- `npm run ensure` confirmed Clinical KB at `http://localhost:4298`. +- Browser QA verified medication prompt -> results -> Acamprosate detail on desktop. +- Browser QA verified direct medication results URL -> Acamprosate detail on extended phone viewport. +- Focused lint passed for the routed medication/app-mode files. +- `npm run typecheck -- --pretty false` passed. +- `npm run verify:ui` passed: 34 Chromium Playwright tests. + +final result: passed + +--- + +**Medication Prescribing App Polish Iteration** + +**Source Visual Truth** + +- Target mockup: `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-e90b72f2-9c58-46e5-bd60-1adca99841e6.png` +- Scope: align the standalone medication mockup, the in-app medication home, medication search results, and the Acamprosate medication detail page with the approved Clinical KB medication direction. + +**Implementation Evidence** + +- Live app route: `http://localhost:4298/?mode=prescribing` +- Medication detail route: `http://localhost:4298/medications/acamprosate` +- Legacy mockup route redirects to: `http://localhost:4298/medications/acamprosate` +- Standalone sheet: `C:\Dev\Apps\Database\output\medication-prescribing-polish\polished-standalone-sheet.png` +- App home desktop: `C:\Dev\Apps\Database\output\medication-prescribing-polish\polished-app-med-home-desktop.png` +- App home phone: `C:\Dev\Apps\Database\output\medication-prescribing-polish\polished-app-med-home-phone.png` +- App results desktop: `C:\Dev\Apps\Database\output\medication-prescribing-polish\polished-app-med-results-desktop.png` +- App results phone: `C:\Dev\Apps\Database\output\medication-prescribing-polish\polished-app-med-results-phone-final.png` +- App detail desktop: `C:\Dev\Apps\Database\output\medication-prescribing-polish\polished-app-med-detail-desktop-v2.png` +- App detail phone: `C:\Dev\Apps\Database\output\medication-prescribing-polish\polished-app-med-detail-phone-v2.png` + +**Design Issues Fixed** + +- Removed the duplicate prescribing loading banner that sat above completed medication results. +- Made the medication home more prompt-led by moving content higher, shrinking the capability rail, and tightening suggestion rows. +- Reworked search results density so rows scan more like a premium clinical search table. +- Changed dose ceiling from a button-like chip to quieter metadata. +- Removed phone filter clipping by shortening the visible filter labels. +- Added stable medication-result selectors for reliable browser QA and interaction testing. +- Improved detail-page responsiveness in the real app shell: the decision tiles now use a cleaner 2x2 layout until very wide screens, preventing crushed labels beside the right rail. +- Fixed clipped phone medication badges by allowing clean wrapping. +- Tightened the standalone mockup composer, phone text sizing, and right rail spacing. +- Replaced the legacy `/mockups/medication-prescribing` route with the in-app Acamprosate medication detail route. + +**Verification** + +- `npm run ensure` confirmed the app at `http://localhost:4298`. +- Focused screenshot QA covered desktop and phone home/results/detail states with no horizontal overflow. +- `npm run lint -- --max-warnings=0 src/app/mockups/medication-prescribing/page.tsx src/components/clinical-dashboard/medication-prescribing-workspace.tsx src/components/ClinicalDashboard.tsx mockups/medication-prescribing/page.tsx` passed. +- `npm run typecheck -- --pretty false` passed. +- `npm run verify:ui` passed: 32 Chromium Playwright tests. + +final result: passed + +--- + +**Medication Prescribing Mockup Polish** + +**Source Visual Truth** + +- Target mockup: `C:\Users\joshs\AppData\Local\Temp\codex-clipboard-e90b72f2-9c58-46e5-bd60-1adca99841e6.png` +- Scope: medication prescribing detail mockup sheet with matched desktop and iPhone layouts, preserving the Clinical KB shell while matching the selected Acamprosate direction. + +**Implementation Evidence** + +- Legacy route: `http://localhost:4298/mockups/medication-prescribing` now redirects to `http://localhost:4298/medications/acamprosate` +- Final screenshot: `C:\Dev\Apps\Database\output\medication-prescribing-polish\medication-mockup-sheet-final.png` +- Checked viewport: 1536x1024. + +**Design Changes Made** + +- Rebuilt the medication prescribing mockup into a single polished desktop-plus-phone sheet instead of multiple older design options. +- Matched the selected structure: compact Clinical KB header, Acamprosate identity block, medication chips, four top decision tiles, clinical ledger, right-side monitoring/access panels, and compact phone summary sections. +- Tightened spacing so the page reads premium and clinical rather than sparse or over-explained. +- Rebalanced the decision tiles so "Prescribing answer" leads, dosing is grouped, "Dose ceiling" reads as metadata, and "Avoid" remains prominent without overwhelming the page. +- Compressed the phone view with smaller decision tiles, one-line badges, a lighter tab bar, cleaner list rows, and a slimmer follow-up composer. +- Hid lower-priority source detail behind a quiet collapsed row. + +**Issues Reviewed And Fixed** + +- Removed the old multi-direction layout that did not match the requested reference. +- Removed excess explanatory copy and kept the screen focused on clinical facts. +- Reduced desktop and phone overflow risk by sizing the sheet to the screenshot viewport and tightening large containers. +- Improved icon scale so icons support scanning without competing with medication content. +- Reduced phone chip wrapping and tile crowding. +- Preserved a restrained teal clinical accent while reserving red mainly for avoidance/safety. + +**Verification** + +- `npm run ensure` confirmed the app at `http://localhost:4298`. +- `npm run lint -- --max-warnings=0 src/app/mockups/medication-prescribing/page.tsx src/lib/app-modes.ts src/components/clinical-dashboard/master-search-header.tsx tests/app-modes.test.ts` passed. +- `npm run typecheck -- --pretty false` passed. +- `npm run verify:ui` passed: 32 Chromium Playwright tests. +- Browser QA screenshot check passed with `scrollWidth=1536`, `scrollHeight=1024`, and no horizontal or vertical page overflow at the 1536x1024 target viewport. + +final result: passed + +--- + **Phase 8 Visual QA Polish** **Scope** + - Compared the live Clinical Guide states against the supplied mockups for mobile empty, mobile answer, mobile documents, and desktop answer. - Focused this pass on density, answer contrast, table preview dominance, document-search parity, and regression checks for sidebar/daily-actions/source/evidence behavior. **Current Screenshots** + - Mobile empty: `C:\Dev\Apps\Database\output\playwright\clinical-guide-phase-8\mobile-empty-current.png` - Mobile answer: `C:\Dev\Apps\Database\output\playwright\clinical-guide-phase-8\mobile-answer-current.png` - Mobile documents home: `C:\Dev\Apps\Database\output\playwright\clinical-guide-phase-8\mobile-documents-home-current.png` @@ -71,6 +290,7 @@ final result: passed - Desktop answer: `C:\Dev\Apps\Database\output\playwright\clinical-guide-phase-8\desktop-answer-current.png` **Changes Made** + - Tightened natural answer spacing, answer icon size, source capsule placement, and mobile source preview positioning. - Increased answer body and action-row contrast to primary heading ink. - Reduced table preview width, elevation, header height, and inner padding so it reads as supporting evidence. @@ -80,12 +300,14 @@ final result: passed - Added an explicit `turbopack.root` in `next.config.ts` after local Next docs review to stabilize `npm run ensure` under this workspace layout. **Regression Coverage** + - Mobile hamburger opens the full Clinical Guide side menu through existing smoke coverage. - Mobile `+` opens the Daily actions sheet, not guide/help. - Documents mode shows search input, filters, sort, result count, and result cards. - Source capsule preview, clinical notes, evidence rows, table expansion, and no-overflow paths are covered by focused smoke/stress checks and the full Chromium UI gate. **Verification** + - `npm run ensure` confirmed the app at `http://localhost:4298`. - `npx playwright test tests/ui-smoke.spec.ts --project=chromium --grep "document search mode lists matching documents and scope actions" --reporter=line` passed. - `npx playwright test tests/ui-smoke.spec.ts --project=chromium --grep "dashboard loads without page overflow at small-mobile|demo answer flow reaches|document search mode lists|clinical table mobile expansion" --reporter=line` passed: 6 tests. diff --git a/docs/branch-cleanup-guide.md b/docs/branch-cleanup-guide.md new file mode 100644 index 0000000000..655fde0c56 --- /dev/null +++ b/docs/branch-cleanup-guide.md @@ -0,0 +1,228 @@ +# Branch Cleanup Guide + +Last reviewed: 2026-06-28 + +This guide defines the safe branch cleanup path for this repository. It is written for branch hygiene only: do not use it to discard source work, resolve merge conflicts, merge product changes, or rewrite history. + +## Goals + +- Keep `main`, the current working branch, and any branch with useful content not yet represented on `main`. +- Delete local or remote branches only when their patch content is already on `main` or the branch is explicitly not useful. +- Preserve dirty worktrees, checked-out branches, detached review worktrees, and uncommitted user work. +- Avoid force pushes, rebases, resets, broad cleanup, or deleting unknown branch state. + +## Safety Rules + +Before deleting anything: + +1. Fetch and prune: + + ```powershell + git fetch --prune origin + ``` + +2. Confirm `main` and `origin/main`: + + ```powershell + git rev-parse main origin/main + ``` + +3. Check current worktree state: + + ```powershell + git status --short --branch + ``` + +4. Inspect worktrees: + + ```powershell + git worktree list --porcelain + ``` + +5. For each candidate branch, check whether it has patch content not on `main`: + + ```powershell + git log --format="%h %s" --right-only --cherry-pick main...BRANCH_NAME + git diff --name-status main...BRANCH_NAME + ``` + +Delete a branch only when the cherry-pick-aware log is empty, or when the branch is deliberately rejected as not useful after review. + +## Current Branch State + +Baseline: + +- `main` and `origin/main`: `9bad09523` (`Merge pull request #83 from BigSimmo/codex/80-20-remediation`) +- Current branch: `codex/rag-retry-telemetry-main` +- Current branch status: behind `origin/codex/rag-retry-telemetry-main` by 1 commit, with substantial uncommitted source changes. Do not switch, reset, or clean this worktree as part of branch cleanup. + +## Cleanup Progress + +Completed on 2026-06-28: + +- Deleted local branch `claude/quizzical-bhaskara-97feda` after confirming it had no patch-unique content beyond `main`. +- Deleted remote branch `origin/revert-72-codex/backup-20260623-233849` after confirming it was only the rollback branch for `Save Codex changes`. +- Removed clean detached worktrees `C:\Dev\Apps\Database-80-20-clean` and `C:\Users\joshs\.codex\worktrees\4468\Database` after confirming their HEAD commits were contained in `main`. +- Reviewed `origin/fix/rag-pipeline-stage3-generation`, `claude/recursing-agnesi-28f476`, and `codex/80-20-remediation`; kept them because they still contain useful or not-yet-committed work. +- Reviewed `copilot/simplify-operational-tooling`; rejected it as a branch to preserve because useful source/test overlap is covered by the current dirty tree or `codex/80-20-remediation`, while the remaining unique content is generated/local agent files, broad dependency-repair tooling, or duplicated env/startup patterns. + +Windows left some unregistered `.claude/worktrees/*` folders locked on disk during cleanup. Treat those as filesystem leftovers, not branch refs, after confirming they do not appear in `git worktree list --porcelain`. + +## Delete Candidates Already On Main + +These add no patch content beyond `main`: + +- None known after the 2026-06-28 cleanup pass. + +## Keep For Review + +These have patch content not represented on `main`. Do not delete before review. + +### `claude/recursing-agnesi-28f476` + +Status: + +- Local branch checked out in `C:\Dev\Apps\Database\.claude\worktrees\recursing-agnesi-28f476` +- 3 patch-unique commits +- 18 files changed + +Content: + +- Node 24 / npm 11 runtime requirement work. +- Retrieval and ingestion performance work. +- HNSW `ef_search = 100` migration and schema updates. +- Changes touch `src/lib/rag.ts`, worker code, runtime scripts, docs, and tests. + +Recommendation: + +- Keep. +- Review and salvage selectively. +- Do not merge wholesale until conflicts with current `main` are resolved and runtime expectations are confirmed. +- The local dirty worktree already appears to include Node 24/npm 11, HNSW `ef_search`, and related committed-generation work. Delete this branch only after that work is committed, ported, or explicitly rejected. + +### `codex/80-20-remediation` + +Status: + +- Local branch only; upstream branch was deleted after its remote content was confirmed merged. +- 8 patch-unique commits +- 19 files changed + +Content: + +- Additional local remediation beyond the already merged PR #83 branch. +- Eval/search privacy fixes. +- Relevance score component. +- Chunking, image filtering, answer formatting, search interaction, and UI smoke test updates. + +Recommendation: + +- Keep for review. +- Cherry-pick or port useful fixes after comparing against current uncommitted work on `codex/rag-retry-telemetry-main`. +- Delete only after useful changes are merged or consciously rejected. +- Review found unported-looking pieces such as `src/components/clinical-dashboard/relevance-score.ts`, `tests/document-relevance-score.test.ts`, and `tests/search-interaction-route.test.ts`, so this branch should remain until those are accepted or rejected. + +### `copilot/simplify-operational-tooling` + +Status: + +- Local branch only; remote branch was deleted because its remote patch content was already represented on `main`. +- 3 patch-unique local commits +- 113 files changed +- Rejected during phase 3 cleanup review. + +Content: + +- Large mixed branch with tooling scripts, local agent skill files, Node/runtime updates, mockups, dashboard and profile UI work, answer formatting changes, and broad tests. + +Recommendation: + +- Delete the local branch ref. +- Do not port `.agents/**`, `skills-lock.json`, `scripts/repair-node-modules.cjs`, `scripts/ensure-next-runtime.mjs`, `scripts/next-build.mjs`, or `scripts/run-local-tool.mjs` from this branch. +- Do not port `src/lib/startup-check.ts` or `src/lib/supabase/env.ts`; current env handling already lives in `src/lib/env.ts`, `src/lib/supabase/client.tsx`, `scripts/check-ci-env.mjs`, and Supabase project checks. +- Keep using `codex/80-20-remediation` as the review source for `relevance-score`, search interaction/eval privacy fixes, and focused tests. + +## Remote Branches Not On Main + +### `origin/claude/recursing-agnesi-28f476` + +Status: + +- 1 patch-unique commit +- 14 files changed + +Content: + +- Remote subset of the local `claude/recursing-agnesi-28f476` work, mainly Node 24 / npm 11 runtime requirement changes. + +Recommendation: + +- Keep while local `claude/recursing-agnesi-28f476` is under review. +- Delete remote only after deciding whether to keep the runtime upgrade work. + +### `origin/fix/rag-pipeline-stage3-generation` + +Status: + +- 1 patch-unique commit +- 7 files changed +- Reviewed during cleanup pass and kept. + +Content: + +- Old Stage 3 generation safety fixes. +- Touches answer verification, RAG routing, RAG trust tests, and ingestion retry route. +- Also includes committed-index-generation filtering in RAG source expansion and answer-prose guardrails. + +Recommendation: + +- Keep for clinical-safety review. +- Compare against current answer verification and ingestion retry code before deciding. +- If still relevant, port the useful tests/fixes rather than merging blindly. +- Current dirty worktree already appears to contain the numeric-verification fixes, retry-route TOCTOU guard, committed-index-generation filtering, and answer prose guardrails. Keep the branch until those changes are committed or safely represented elsewhere. + +## Detached Worktrees + +These are not branch refs, so do not treat them as branch cleanup until inspected separately. + +Removed after clean/main-contained verification: + +- `C:\Dev\Apps\Database-80-20-clean` +- `C:\Users\joshs\.codex\worktrees\4468\Database` + +Required checks before removing either: + +```powershell +git -C PATH status --short --branch +git -C PATH log -1 --oneline +git worktree list --porcelain +``` + +Remove only if clean and no longer needed: + +```powershell +git worktree remove PATH +``` + +## Recommended Next Cleanup Order + +1. Commit, port, or explicitly reject the current dirty work that appears to subsume `origin/fix/rag-pipeline-stage3-generation` and parts of `claude/recursing-agnesi-28f476`. +2. Review local `codex/80-20-remediation`; port useful extra remediation or delete. +3. Delete reviewed branches only after their useful changes are either represented in committed history or intentionally rejected. + +## Final Verification + +After each cleanup pass: + +```powershell +git fetch --prune origin +git branch --all --verbose --no-abbrev +git worktree list --porcelain +git status --short --branch +``` + +Expected invariant: + +- `main` remains unchanged. +- The current dirty worktree remains untouched. +- No branch with patch-unique commits is deleted unless its content was explicitly rejected or safely ported first. diff --git a/docs/clinical-chat-ui-implementation-plan.md b/docs/clinical-chat-ui-implementation-plan.md index d34864c3c0..8a1c694092 100644 --- a/docs/clinical-chat-ui-implementation-plan.md +++ b/docs/clinical-chat-ui-implementation-plan.md @@ -125,17 +125,17 @@ Use one calm humanist sans direction throughout, aligned with the current app fo Recommended scale: -| Use | Size | Weight | Line height | Notes | -| --- | --- | --- | --- | --- | -| Page/app title | 18-20px | 600 | 1.25 | Desktop sidebar/header only | -| Section heading | 15-17px | 600 | 1.35 | Keep compact | -| Answer paragraph | 15-16px | 450-500 | 1.55-1.7 | Primary reading surface | -| Bullets | 14-15px | 450-550 | 1.55-1.65 | No bullet icons | -| Sidebar labels | 13px | 500-600 | 1.3 | Icons secondary | -| Metadata/chips | 12-13px | 500-600 | 1.25 | Avoid tiny unreadable text | -| Table header | 12-13px | 600 | 1.25 | Blue-grey header | -| Table cells | 12-13px | 450-500 | 1.35 | Accessible expanded view required | -| Mobile composer | 15-16px | 400-500 | 1.3 | Prevent mobile zoom | +| Use | Size | Weight | Line height | Notes | +| ---------------- | ------- | ------- | ----------- | --------------------------------- | +| Page/app title | 18-20px | 600 | 1.25 | Desktop sidebar/header only | +| Section heading | 15-17px | 600 | 1.35 | Keep compact | +| Answer paragraph | 15-16px | 450-500 | 1.55-1.7 | Primary reading surface | +| Bullets | 14-15px | 450-550 | 1.55-1.65 | No bullet icons | +| Sidebar labels | 13px | 500-600 | 1.3 | Icons secondary | +| Metadata/chips | 12-13px | 500-600 | 1.25 | Avoid tiny unreadable text | +| Table header | 12-13px | 600 | 1.25 | Blue-grey header | +| Table cells | 12-13px | 450-500 | 1.35 | Accessible expanded view required | +| Mobile composer | 15-16px | 400-500 | 1.3 | Prevent mobile zoom | Reading constraints: @@ -147,16 +147,16 @@ Reading constraints: Target tokens: -| Token | Value | Use | -| --- | --- | --- | -| Deep teal | `#006C67` | active tab, send, source capsule text/border | -| Pale teal | `#E7F4F2` | active tab/source capsule background | -| Clinical sand | `#F7F1E6` | Clinical notes row only | -| Document blue-grey | `#EEF3F6` | Documents mode/search surfaces | -| Table blue-grey | `#F4F7F8` | Table header | -| Amber dot | `#C88719` | older/review-due source dot only | -| Ready green dot | `#1A8F5A` | profile/current/high-confidence dot only | -| Ink navy | existing app ink token or `#13232B` | primary text | +| Token | Value | Use | +| ------------------ | ----------------------------------- | -------------------------------------------- | +| Deep teal | `#006C67` | active tab, send, source capsule text/border | +| Pale teal | `#E7F4F2` | active tab/source capsule background | +| Clinical sand | `#F7F1E6` | Clinical notes row only | +| Document blue-grey | `#EEF3F6` | Documents mode/search surfaces | +| Table blue-grey | `#F4F7F8` | Table header | +| Amber dot | `#C88719` | older/review-due source dot only | +| Ready green dot | `#1A8F5A` | profile/current/high-confidence dot only | +| Ink navy | existing app ink token or `#13232B` | primary text | Rules: @@ -189,21 +189,21 @@ Do not put icons beside every bullet or source metadata item. Mapping: -| Feature | Icon | -| --- | --- | -| New chat | plus in rounded square | -| Search library | magnifying glass | -| Scope | filter/funnel or globe-filter | -| Evidence | stacked layers | -| Clinical notes | clipboard/check | -| Documents | file text | -| Images | image | -| Tables | grid/table | -| PDFs | file badge | +| Feature | Icon | +| -------------- | --------------------------------- | +| New chat | plus in rounded square | +| Search library | magnifying glass | +| Scope | filter/funnel or globe-filter | +| Evidence | stacked layers | +| Clinical notes | clipboard/check | +| Documents | file text | +| Images | image | +| Tables | grid/table | +| PDFs | file badge | | Clinical tools | toolkit or stethoscope, sparingly | -| Guide | question circle | -| Settings | gear | -| Profile | initials only | +| Guide | question circle | +| Settings | gear | +| Profile | initials only | Status: @@ -225,17 +225,17 @@ Likely primary files: Mapping: -| Current area | Final design role | -| --- | --- | -| MasterSearchHeader | simplified top bar, Answer/Documents, scope, composer | -| ClinicalDashboard | app shell, sidebar, answer/evidence/document layout | -| UtilityDrawer | standard collapsed row / desktop accordion / mobile sheet | -| AnswerEmptyState | quiet starter state | -| VisualEvidenceStrip | evidence drawer Images/Tables sections | -| QuoteCards | evidence drawer Quotes section | -| SourceList | evidence drawer Sources section | -| DocumentSearchResultsPanel | Documents mode result cards | -| AccessibleTable | compact inline table and accessible expanded table | +| Current area | Final design role | +| -------------------------- | --------------------------------------------------------- | +| MasterSearchHeader | simplified top bar, Answer/Documents, scope, composer | +| ClinicalDashboard | app shell, sidebar, answer/evidence/document layout | +| UtilityDrawer | standard collapsed row / desktop accordion / mobile sheet | +| AnswerEmptyState | quiet starter state | +| VisualEvidenceStrip | evidence drawer Images/Tables sections | +| QuoteCards | evidence drawer Quotes section | +| SourceList | evidence drawer Sources section | +| DocumentSearchResultsPanel | Documents mode result cards | +| AccessibleTable | compact inline table and accessible expanded table | ## Final default UI states diff --git a/docs/process-hardening.md b/docs/process-hardening.md index b6742a82f1..ad8ce6aabf 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. - `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 `/tools` launcher at mobile and desktop sizes. +- `tests/ui-tools.spec.ts` covers the `/applications` launcher at mobile and desktop sizes. - `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. -- `/tools` now has dedicated Playwright coverage in the UI gate. +- `/applications` now has dedicated Playwright coverage in the UI gate. ## Phase 3 - Structural cleanup diff --git a/docs/reindex-runbook.md b/docs/reindex-runbook.md index 95f4b9ecba..2a40eafd59 100644 --- a/docs/reindex-runbook.md +++ b/docs/reindex-runbook.md @@ -14,11 +14,11 @@ This single command encapsulates the full safe sequence below: it confirms the t Options: -| Flag | Default | Description | -|---|---|---| -| `--yes` | off | Skip confirmation prompts (non-interactive / CI use) | -| `--max-rounds` | 10 | Maximum worker iterations before stopping | -| `--limit` | 20 | Recovery action limit per round | +| Flag | Default | Description | +| -------------- | ------- | ---------------------------------------------------- | +| `--yes` | off | Skip confirmation prompts (non-interactive / CI use) | +| `--max-rounds` | 10 | Maximum worker iterations before stopping | +| `--limit` | 20 | Recovery action limit per round | ```sh # Non-interactive, up to 5 worker rounds: diff --git a/eslint.config.mjs b/eslint.config.mjs index c388f1c693..cea1650b8c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,9 @@ const eslintConfig = defineConfig([ ".claude/**", "playwright-report/**", "test-results/**", + "node_modules.broken-*/**", + ".node_modules.stale-*/**", + ".npm-cache*/**", ".tmp-visual/**", "sample-documents/**", "scratch/**", diff --git a/mockups/README.md b/mockups/README.md index 7b96b91572..f2d3cc7ea9 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -4,18 +4,35 @@ This folder collects the current mockup files for the Clinical KB Database proje ## Included mockups -- `favourites-hub/page.tsx` - copied from `src/app/mockups/favourites-hub/page.tsx` -- `medication-prescribing/page.tsx` - copied from `src/app/mockups/medication-prescribing/page.tsx` +- Medication prescribing now lives in the app at `/?mode=prescribing` and `/medications/acamprosate`. - `answer-evidence-popups/page.tsx` - copied from `src/app/mockups/answer-evidence-popups/page.tsx` -- `user-home-profile/page.tsx` - copied from `src/app/mockups/user-home-profile/page.tsx` - `mode-dropdown` - runnable mockup only, in `src/app/mockups/mode-dropdown/page.tsx` +- `recent-searches-bottom` - runnable mockup only, in `src/app/mockups/recent-searches-bottom/page.tsx` +- `settings-search-general` - runnable mockup only, in `src/app/mockups/settings-search-general/page.tsx` +- `settings-search-clinical` - runnable mockup only, in `src/app/mockups/settings-search-clinical/page.tsx` +- `settings-search-privacy` - runnable mockup only, in `src/app/mockups/settings-search-privacy/page.tsx` ## App routes The runnable versions remain in the Next.js app route tree: -- `/mockups/favourites-hub` -- `/mockups/medication-prescribing` +- `/?mode=prescribing` +- `/medications/acamprosate` - `/mockups/answer-evidence-popups` -- `/mockups/user-home-profile` - `/mockups/mode-dropdown` +- `/mockups/recent-searches-bottom` + +Favourites now lives in the live dashboard flow at `/?mode=favourites`; `/mockups/favourites-hub` redirects there for old links. + +## Global search shell + +New runnable mockups under `src/app/mockups/*` inherit the shared Clinical KB header and bottom search composer from +`src/app/mockups/layout.tsx`. + +- Put the mockup content between the global header and bottom composer; do not copy the header or composer into new pages. +- Use `?mode=answer`, `?mode=documents`, `?mode=prescribing`, `?mode=evidence`, or `?mode=favourites` to preview the active search mode. +- The bottom composer routes live searches to the dashboard with `mode`, `q`, and `run=1`; New chat routes to `/?mode=answer&focus=1`. +- If a future mockup must be standalone, move it outside the `/mockups` route shell or add an explicit opt-out route group before implementing it. +- `/mockups/settings-search-general` +- `/mockups/settings-search-clinical` +- `/mockups/settings-search-privacy` diff --git a/mockups/favourites-hub/page.tsx b/mockups/favourites-hub/page.tsx deleted file mode 100644 index 86ab0b1935..0000000000 --- a/mockups/favourites-hub/page.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import { - BadgeCheck, - BookOpen, - BookOpenCheck, - ChevronDown, - Clock, - Database, - ExternalLink, - FileText, - Filter, - Folder, - HeartPulse, - ListChecks, - Pill, - Pin, - Plus, - Search, - ShieldAlert, - Sparkles, - Star, - Stethoscope, - Tag, - Target, -} from "lucide-react"; -import type { ReactNode } from "react"; - -const favouriteStats = [ - ["Saved items", "42", "Across sources, medications, documents, searches, and clinical tools"], - ["Review due", "6", "Favourites with source governance or medication safety checks pending"], - ["Pinned sets", "5", "Reusable clinical bundles for ward, clinic, and prescribing workflows"], - ["Used today", "18", "Recently opened, scoped, copied, or added to an answer"], -] as const; - -const categoryTabs = [ - ["All", "42"], - ["Sources", "14"], - ["Medications", "9"], - ["Documents", "12"], - ["Searches", "4"], - ["Tools", "3"], -] as const; - -const favouriteCards = [ - { - title: "Clozapine physical health protocol", - type: "Document", - detail: "Shared-care thresholds, monitoring cadence, escalation actions, and metabolic risk tables.", - meta: "42 pages - review due - 4 tables", - accent: "review", - icon: FileText, - tags: ["Protocol", "Clozapine", "Monitoring"], - }, - { - title: "Lithium monitoring quick set", - type: "Favourite set", - detail: "Document scope, renal/thyroid source passages, toxicity warning table, and patient leaflet.", - meta: "4 saved items - used 2h ago", - accent: "current", - icon: Folder, - tags: ["Pinned", "Shared care", "Renal"], - }, - { - title: "Acamprosate", - type: "Medication", - detail: "Quick prescribing, renal screen, dose limits, PBS access, and source-backed safety notes.", - meta: "S4 - reviewed - prescribing page", - accent: "medication", - icon: Pill, - tags: ["AUD", "Dose", "Renal screen"], - }, - { - title: "ECT referral and consent pathway", - type: "Source", - detail: "Workflow pages, consent checkpoints, capacity considerations, and related forms grouped together.", - meta: "Current - policy - 12 pages", - accent: "current", - icon: BookOpenCheck, - tags: ["Workflow", "Consent", "Forms"], - }, -] as const; - -const savedCollections = [ - ["Ward round", "Lithium, clozapine, rapid tranquillisation, ECT forms", "12 items"], - ["Prescribing safety", "Dose pages, renal alerts, pregnancy cautions, PBS source checks", "9 items"], - ["Document QA", "Review due sources, low extraction quality, missing tables", "6 items"], -] as const; - -const timelineRows = [ - ["Pinned", "Acamprosate prescribing page", "Medication"], - ["Added", "Clozapine protocol table 4", "Source"], - ["Scoped", "Lithium monitoring quick set", "Favourite set"], - ["Reviewed", "ECT referral and consent pathway", "Document"], -] as const; - -const addOptions = [ - ["Current answer", "Save the answer, citations, source previews, and clinical note context.", Sparkles], - ["Medication page", "Pin dose, safety, monitoring, access, and source review state together.", Pill], - ["Document or source", "Add a PDF, guideline, table passage, image, or signed source link.", FileText], - ["Search scope", "Save query, filters, document scope, and ranking explanation as a reusable set.", Search], -] as const; - -function toneClass(accent: (typeof favouriteCards)[number]["accent"]) { - if (accent === "review") { - return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; - } - if (accent === "medication") { - return "border-[color:var(--primary)]/30 bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)]"; - } - return "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]"; -} - -function StatCard({ label, value, body }: { label: string; value: string; body: string }) { - return ( -
-

{label}

-

{value}

-

{body}

-
- ); -} - -function ActionButton({ children, primary = false }: { children: ReactNode; primary?: boolean }) { - const className = primary - ? "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[color:var(--clinical-chat-teal)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-tight)] hover:bg-[color:var(--primary-strong)]" - : "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-semibold text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]"; - - return ( - - ); -} - -function FavouriteCard({ item }: { item: (typeof favouriteCards)[number] }) { - const Icon = item.icon; - return ( -
-
- - - -
-
- - {item.type} - - - {item.accent === "review" ? "Review due" : item.accent === "medication" ? "Prescribing" : "Current"} - -
-

{item.title}

-

{item.detail}

-

{item.meta}

-
- {item.tags.map((tag) => ( - - - {tag} - - ))} -
-
-
- - -
-
-
- ); -} - -function AddOption({ option }: { option: (typeof addOptions)[number] }) { - const [title, body, Icon] = option; - return ( - - ); -} - -export default function FavouritesHubMockupPage() { - return ( -
-
-
-
-
-
- - - -

- Clinical KB favourites mockup -

-
-

- A source-first favourites hub for saved clinical work -

-

- Save medications, documents, source passages, searches, clinical tools, and reusable bundles into one - fast workspace without losing review state, provenance, or prescribing safety context. -

-
- - - Add favourite - - - - Manage pinned sets - - - - Review due - -
-
- -
-
- -
- {favouriteStats.map(([label, value, body]) => ( - - ))} -
- -
-
-
-
-
-

- Browse favourites -

-

- Saved work, grouped by clinical use -

-
-
- - - Filters - - - - - Recent first - -
-
- -
- -
- {categoryTabs.map(([label, count], index) => ( - - ))} -
-
-
- -
- {favouriteCards.map((item) => ( - - ))} -
-
- - -
- -
-
-
-
- -

Hub structure

-
- - Source-backed by default - -
-
- {[ - ["Save anywhere", "Add from answer actions, source cards, medication pages, document rows, and search scope.", Target], - ["Reuse safely", "Open with review status, original citations, filters, and document scope already attached.", BadgeCheck], - ["Stay organised", "Collections, pinned sets, recency, tags, and review queues keep high-volume libraries scannable.", Database], - ].map(([title, body, Icon]) => ( -
- -

{title as string}

-

{body as string}

-
- ))} -
-
- - -
- -
-
-
-
- -

Clinical guardrails

-
-

- Favourites should make repeat work faster without stripping away the source trail. -

-
-
- {[ - ["Medication favourites", "Always show dose limiter, review state, and contraindication summary.", Stethoscope], - ["Document favourites", "Keep page, chunk, table/image counts, review status, and best passage.", BookOpen], - ["Saved searches", "Persist query mode, filters, scope, result ordering, and coverage notes.", Search], - ].map(([title, body, Icon]) => ( -
- -

{title as string}

-

{body as string}

-
- ))} -
-
-
-
-
- ); -} diff --git a/mockups/medication-prescribing/page.tsx b/mockups/medication-prescribing/page.tsx index 503130c6e9..fe80db51ed 100644 --- a/mockups/medication-prescribing/page.tsx +++ b/mockups/medication-prescribing/page.tsx @@ -1,494 +1,709 @@ import { Activity, AlertTriangle, + ArrowLeftRight, BadgeCheck, - BookOpenCheck, + Brain, + CalendarDays, + ChartNoAxesColumnIncreasing, + CheckCircle2, + ChevronDown, + ChevronRight, ClipboardCheck, + ClipboardList, + Droplet, FileText, - Gauge, - HeartPulse, - ListChecks, - MonitorCheck, + FlaskConical, + Lock, + Mic, + Paperclip, Pill, - Search, - ShieldAlert, - Sparkles, - Stethoscope, + Plus, + Send, + ShieldCheck, + UserRound, + type LucideIcon, } from "lucide-react"; -const quickRows = [ - ["Indication", "Alcohol abstinence maintenance"], - ["Form", "333 mg enteric-coated tablet"], - ["Typical dose", "666 mg three times daily with meals"], - ["Maximum", "1998 mg/day"], - ["Schedule", "S4"], - ["Access", "Streamlined PBS authority"], +type LedgerColumn = { + label: string; + value: string; + meta?: string; +}; + +type LedgerRowData = { + label: string; + icon: LucideIcon; + body?: string | readonly string[]; + columns?: readonly LedgerColumn[]; + tone?: "danger"; + compact?: boolean; +}; + +const badges = ["333 mg EC tablet", "PBS streamlined", "Reviewed"] as const; + +const decisionTiles = [ + { + label: "Prescribing answer", + value: "Maintenance after detox", + meta: "with psychosocial support", + icon: CheckCircle2, + tone: "teal", + }, + { + label: "Dosing", + value: "666 mg TID", + meta: "2 x 333 mg", + icon: CalendarDays, + tone: "teal", + }, + { + label: "Dose ceiling", + value: "1,998 mg/day", + meta: "MAX", + icon: ChartNoAxesColumnIncreasing, + tone: "teal", + }, + { + label: "Avoid", + value: "Cr >120", + meta: "micromol/L", + icon: AlertTriangle, + tone: "danger", + }, ] as const; -const safetyRows = [ - ["Renal", "Do not use if renal insufficiency is present; check serum creatinine before prescribing."], - ["Hepatic", "Avoid severe hepatic failure. Mild to moderate impairment still needs review."], - ["Pregnancy", "Contraindicated in pregnancy and breastfeeding in the reviewed PI."], - ["Age", "Avoid under 18 and over 65 where safety and efficacy are not established."], +const ledgerRows: readonly LedgerRowData[] = [ + { + label: "Prescribing answer", + icon: ClipboardList, + body: [ + "Use for maintenance of abstinence after detox when renal function is acceptable.", + "Start after withdrawal and continue if relapse occurs. Not for use in acute withdrawal.", + ], + }, + { + label: "Dosing", + icon: CalendarDays, + columns: [ + { label: "Usual dose", value: "666 mg (2 x 333 mg) TID with meals" }, + { label: "Dose ceiling", value: "1,998 mg/day", meta: "MAX" }, + { label: "Under 60 kg", value: "2 tablets morning, 1 midday, 1 night" }, + { label: "Treatment duration", value: "Around 1 year" }, + ], + }, + { + label: "Administration", + icon: Pill, + body: ["Take with food. Swallow EC tablets whole with water.", "Do not crush or chew."], + }, + { + label: "Do not use", + icon: AlertTriangle, + tone: "danger", + body: [ + "Renal insufficiency: serum creatinine >120 micromol/L (contraindicated)", + "Severe hepatic failure (Child-Pugh C) (contraindicated)", + "Pregnancy (DO NOT USE)", + "Breastfeeding (DO NOT USE)", + ], + }, + { + label: "Populations", + icon: UserRound, + body: "Avoid in children/adolescents under 18 years and in adults over 65 years: safety and efficacy not established.", + }, + { + label: "Key risks", + icon: ShieldCheck, + columns: [ + { label: "GI", value: "Diarrhea, nausea, flatulence (high)" }, + { label: "Dermatologic", value: "Rash, pruritus" }, + { label: "Neuropsychiatric", value: "Mood swings, depression" }, + ], + }, + { + label: "Pearls / PK", + icon: FlaskConical, + compact: true, + body: "Mechanism not fully established - Not metabolized; excreted unchanged in urine - Half-life 13-28.4 h - Minimal protein binding", + }, ] as const; const monitoringRows = [ - ["Baseline", "Renal function, hepatic status, pregnancy or lactation status, alcohol withdrawal completion."], - ["Early review", "Adherence, gastrointestinal intolerance, mood symptoms, ongoing alcohol use."], - ["Ongoing", "Renal function if clinical status changes; continue psychosocial support."], + { label: "Renal", body: "Check baseline and periodically", icon: Droplet }, + { label: "Hepatic (severe disease)", body: "Assess if severe liver disease suspected", icon: ShieldCheck }, + { label: "Mood / suicidality", body: "Monitor, especially early treatment", icon: Brain }, + { label: "Adherence", body: "Reinforce adherence and support", icon: ClipboardCheck }, ] as const; -const sourceRows = [ - ["Australian PI", "Reviewed source - Campral product information"], - ["PBS", "Streamlined authority item present"], - ["Clinical KB", "Related guideline passages available"], +const interactionRows = [ + "Diazepam, disulfiram, imipramine: no major PK interactions.", + "Naltrexone: increases acamprosate exposure; no dose adjustment required.", + "Other psychotropics: not well studied.", ] as const; -const navItems = ["Summary", "Dose", "Safety", "Monitoring", "Sources"] as const; - -function MiniStat({ label, value, tone = "neutral" }: { label: string; value: string; tone?: "neutral" | "warn" | "ok" }) { - const toneClass = - tone === "warn" - ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" - : tone === "ok" - ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" - : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)]"; +const mobileRows = [ + { + label: "Prescribing answer", + icon: ClipboardList, + body: "Use for maintenance after detox when renal function is acceptable. Start after withdrawal and continue if relapse occurs.", + }, + { + label: "Dosing", + icon: CalendarDays, + body: "Usual: 666 mg (2 x 333 mg) TID with meals. Max: 1,998 mg/day. <60 kg: 2 tabs morning, 1 midday, 1 night. Duration: around 1 year.", + }, + { + label: "Administration", + icon: Pill, + body: "Take with food. Swallow EC tablets whole with water. Do not crush or chew.", + }, + { + label: "Do not use", + icon: AlertTriangle, + body: "Renal insufficiency (Cr >120), severe hepatic failure (Child-Pugh C), pregnancy, breastfeeding.", + tone: "danger", + }, + { + label: "Populations", + icon: UserRound, + body: "Avoid <18 years and >65 years: safety and efficacy not established.", + }, +] as const; +function IconFrame({ + icon: Icon, + tone = "teal", + className = "h-9 w-9", +}: { + icon: LucideIcon; + tone?: "teal" | "danger" | "slate"; + className?: string; +}) { return ( -
-

{label}

-

{value}

-
+ + ); } -function SearchResultMock({ variant }: { variant: "compact" | "split" | "clinical" }) { - const resultClass = - variant === "split" - ? "grid gap-3 lg:grid-cols-[1fr_22rem]" - : variant === "clinical" - ? "grid gap-3 xl:grid-cols-[minmax(0,1fr)_18rem]" - : "grid gap-3"; - +function TopNav({ compact = false }: { compact?: boolean }) { return ( -
-
-
- - +
+

+ Clinical KB +

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

Search: acamprosate dose renal

-

- Medication result appears beside source-backed documents, not instead of them. +

Acamprosate

+

+ Alcohol abstinence maintenance · GABA / + glutamate modulator

-
- Answer - Sources - - Medication - + +
+ ); + } + + return ( +
+ +
+

Acamprosate

+

+ Alcohol abstinence maintenance · GABA / glutamate + modulator +

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

+ {tile.label} +

+

+ {compact ? compactBody : tile.value} +

+ {showMeta ? ( +

+ {tile.meta} +

+ ) : null}
+
+ ); +} + +function normalizeBody(body: string | readonly string[] | undefined): readonly string[] { + if (!body) { + return []; + } -
- +
+ +

-

- + ); +} - {variant !== "compact" ? ( -
-

Matched evidence

-
- {sourceRows.slice(0, variant === "split" ? 2 : 3).map(([label, value]) => ( -
- -

- {label}: - {value} -

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

+ {item.label} +

+

+ {item.body} +

- ) : null} + ))}
); } -function RowList({ rows }: { rows: readonly (readonly [string, string])[] }) { +function InteractionsPanel() { return ( -
- {rows.map(([label, value]) => ( -
-

{label}

-

{value}

-
- ))} -
+
+
+
+
    + {interactionRows.map((item) => ( +
  • + + {item} +
  • + ))} +
+
); } -function PageHeader({ direction, title, body }: { direction: string; title: string; body: string }) { +function AccessPanel() { return ( -
-
-

{direction}

-

{title}

-

{body}

+
+
+
-
- {navItems.map((item, index) => ( - + {[ + ["Brand", "Campral"], + ["PBS status", "PBS streamlined"], + ["PBS item", "8357W"], + ].map(([label, value], index) => ( +
- {item} - +
{label}
+
{value}
+
))} -
-
+ +
); } -function DirectionOne() { +function SourcesRow({ compact = false }: { compact?: boolean }) { return ( -
- -
- -
-
-
-
-
-

Medication page

-

Acamprosate

-

- Addiction Medicine - GABA / Glutamate modulator - S4 -

-
- - Source reviewed - -
-
- - - - -
-
- -
-
- -

Quick prescribing

-
- -
- -
-
- -

Safety checks before signing

-
- -
-
- - -
+
+
+
+
); } -function DirectionTwo() { +function DesktopComposer() { return ( -
- -
- -
-
-
-

Acamprosate prescribing workspace

-

- Everything needed to start, avoid, adjust, and monitor without leaving the page. -

-
-
- - AUD - - - PBS - - - TGA - -
-
+
+
+ +
+ Ask follow-up or add context... +
+
+
+ ); +} -
- {[ - ["Start", "666 mg TDS after withdrawal period", Stethoscope], - ["Adjust", "Weight under 60 kg uses lower daily split", Gauge], - ["Avoid", "Renal insufficiency, pregnancy, breastfeeding", ShieldAlert], - ["Monitor", "Renal function, adherence, mood, GI effects", MonitorCheck], - ].map(([label, value, Icon]) => ( -
- -

{label as string}

-

{value as string}

-
+function DesktopMockup() { + return ( +
+ +
+
+ +
+ {decisionTiles.map((tile) => ( + ))}
- -
-
-
-
- -

Prescribing data

-
- -
-
-
- -

Monitoring

-
- -
-
- -
-
+
+ {ledgerRows.map((row) => ( + + ))} +
+ +
+
); } -function DirectionThree() { +function MobileTabs() { return ( -
- -
- -
- +
+ {["Summary", "Dosing", "Safety", "More"].map((tab, index) => ( + + {tab} + + ))} +
+ ); +} -
-
-
- -

Prescribing summary

-
-

- Consider for alcohol abstinence maintenance after withdrawal when renal function, pregnancy or lactation - status, hepatic severity, and age do not trigger an avoid rule. -

-
+function MobileRow({ row }: { row: (typeof mobileRows)[number] }) { + const danger = "tone" in row && row.tone === "danger"; + return ( +
+ +
+
+

+ {row.label} +

+
+

{row.body}

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

Dose and access

-
- -
-
-
- -

Safety screen

-
- -
-
-
+function MobileCollapsedRow({ label, icon: Icon }: { label: string; icon: LucideIcon }) { + return ( +
+ + +
+ ); +} - -
+function MobileComposer() { + return ( +
+
+ +
+ Ask follow-up... +
+
-
+ ); } -export default function MedicationPrescribingMockupsPage() { +function PhoneMockup() { return ( -
-
-
-
-
-

Clinical KB medication search mockups

-

- Three lightweight directions for prescribing-focused medication pages -

-

- These mockups keep the quiet Clinical KB teal and source-first styling while borrowing the Medication app - information model: quick prescribing, decision summary, safety screen, monitoring, patient-context checks, - and source review status. -

-
-
-

Design recommendation

-

- Direction 1 is the strongest default: lowest cognitive load, closest to the current answer workflow, and - easiest to ship incrementally. -

-
+
- - - - + +
+ {mobileRows.map((row) => ( + + ))} +
+
+ + + + +
+
+ +
+ + ); +} -
-
- -

Review notes

-
-
-

- Current Clinical KB works best when primary actions stay teal, clinical notes use sand, and evidence remains - close to the answer. The mockups preserve that rhythm. -

-

- Medication app content maps cleanly to search tabs plus pages: stats, quick rows, sections, patient metadata, - source status, and related guideline evidence. -

-

- The implementation should start read-only and source-backed. Patient-context matching can be layered in after - the basic medication tab and page route are reliable. -

-
-
+export default function MedicationPrescribingMockupsPage() { + return ( +
+
+ +
); diff --git a/mockups/user-home-profile/page.tsx b/mockups/user-home-profile/page.tsx deleted file mode 100644 index 48a94fa75a..0000000000 --- a/mockups/user-home-profile/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../src/app/mockups/user-home-profile/page"; diff --git a/package-lock.json b/package-lock.json index 42319c860f..24a0c933dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "eslint": "^9.39.4", "eslint-config-next": "16.2.9", "playwright": "^1.61.1", - "prettier": "^3.8.4", + "prettier": "^3.9.0", "tailwindcss": "^4.3.1", "tsx": "^4.22.4", "typescript": "^6.0.3", @@ -8239,9 +8239,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.0.tgz", + "integrity": "sha512-LjIqSIC5VYLzs9WedVmJ2ljNAGnU+DteIClbahu4L/DBeWjZ6iT/k1lAYyu9JUh+1xINxWadaPw/Pl63y/agAw==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index fa4b7c60e3..65b80a2195 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "prompt-for-codex-medical-knowledge-base", "version": "0.1.0", "private": true, - "packageManager": "npm@11.12.1", + "packageManager": "npm@11.17.0", "engines": { "node": "24.x", "npm": "11.x" @@ -25,6 +25,7 @@ "verify:cheap": "npm run check:runtime && npm run lint && npm run typecheck && npm run test", "verify:ui": "npm run check:runtime && npm run test:e2e:chromium", "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e", + "ci:env-check": "node scripts/check-ci-env.mjs", "check:runtime": "tsx scripts/check-runtime.ts", "check:edge:functions": "node scripts/check-edge-functions.mjs", "check:production-readiness": "tsx scripts/production-readiness.ts", @@ -108,7 +109,7 @@ "eslint": "^9.39.4", "eslint-config-next": "16.2.9", "playwright": "^1.61.1", - "prettier": "^3.8.4", + "prettier": "^3.9.0", "tailwindcss": "^4.3.1", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/scripts/backfill-enrichment.ts b/scripts/backfill-enrichment.ts index 1ee382db69..69d25cec8e 100644 --- a/scripts/backfill-enrichment.ts +++ b/scripts/backfill-enrichment.ts @@ -244,7 +244,7 @@ async function insertDocumentLevelEmbeddingFields(args: { .in( "field_type", inputs.map((input) => input.field_type), - ); + ); if (existing.error) throw new Error(supabaseErrorMessage(existing.error)); const existingKeys = new Set((existing.data ?? []).map((row) => `${row.field_type}:${row.content_hash}`)); const missing = inputs.filter((input) => !existingKeys.has(`${input.field_type}:${hashContent(input.content)}`)); @@ -271,7 +271,10 @@ async function insertDocumentLevelEmbeddingFields(args: { } async function countRows(supabase: SupabaseAdmin, table: string, documentId: string) { - const query = supabase.from(table).select("document_id", { count: "exact", head: true }).eq("document_id", documentId); + const query = supabase + .from(table) + .select("document_id", { count: "exact", head: true }) + .eq("document_id", documentId); const result = await query; if (result.error) throw new Error(supabaseErrorMessage(result.error)); return result.count ?? 0; diff --git a/scripts/check-ci-env.mjs b/scripts/check-ci-env.mjs new file mode 100644 index 0000000000..f2928a1591 --- /dev/null +++ b/scripts/check-ci-env.mjs @@ -0,0 +1,91 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +function loadLocalEnvFile(fileName) { + const envPath = path.join(process.cwd(), fileName); + if (!existsSync(envPath)) return; + + for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) { + if (line.trimStart().startsWith("#")) continue; + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) continue; + + const [, name, rawValue] = match; + if (process.env[name] !== undefined) continue; + process.env[name] = rawValue.trim().replace(/^(['"])(.*)\1$/, "$2"); + } +} + +function isTruthy(value) { + return ["1", "true", "yes", "on"].includes( + String(value ?? "") + .trim() + .toLowerCase(), + ); +} + +function isPlaceholder(value) { + return /<[^>]+>|^your-|replace-with|placeholder/i.test(value); +} + +function readEnv(name) { + return process.env[name]?.trim() ?? ""; +} + +function missingEnv(names) { + return names.filter((name) => { + const value = readEnv(name); + return !value || isPlaceholder(value); + }); +} + +function validateSupabaseUrl(problems) { + const value = readEnv("NEXT_PUBLIC_SUPABASE_URL"); + if (!value || isPlaceholder(value)) return; + + try { + const url = new URL(value); + if (url.protocol !== "https:") problems.push("NEXT_PUBLIC_SUPABASE_URL must use https."); + } catch { + problems.push("NEXT_PUBLIC_SUPABASE_URL must be a valid URL."); + } +} + +loadLocalEnvFile(".env.local"); + +const requiredPublicEnv = ["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY"]; +const requiredE2EEnv = ["E2E_USER_EMAIL", "E2E_USER_PASSWORD"]; +const problems = []; +const missingPublic = missingEnv(requiredPublicEnv); + +if (missingPublic.length > 0) { + problems.push(`Missing required Supabase env: ${missingPublic.join(", ")}`); +} + +validateSupabaseUrl(problems); + +const hasAnyE2EEnv = requiredE2EEnv.some((name) => Boolean(readEnv(name))); +const e2eEnabled = isTruthy(process.env.E2E_AUTH_ENABLED) || hasAnyE2EEnv; + +if (e2eEnabled) { + const missingE2E = missingEnv(requiredE2EEnv); + if (missingE2E.length > 0) { + problems.push(`E2E auth is enabled but missing: ${missingE2E.join(", ")}`); + } +} + +if (problems.length > 0) { + console.error("CI environment check failed:"); + for (const problem of problems) console.error(`- ${problem}`); + console.error(""); + console.error("Required public-safe browser env:"); + for (const name of requiredPublicEnv) console.error(`- ${name}`); + console.error(""); + console.error("Optional E2E auth env, stored as CI/local secrets when E2E auth is enabled:"); + for (const name of requiredE2EEnv) console.error(`- ${name}`); + console.error(""); + console.error("Never use SUPABASE_SERVICE_ROLE_KEY or any service_role key in browser/E2E client setup."); + process.exit(1); +} + +console.log(`CI environment check passed. E2E auth ${e2eEnabled ? "enabled" : "disabled"}; no secret values printed.`); diff --git a/scripts/check-edge-functions.mjs b/scripts/check-edge-functions.mjs index 6adf6507f0..860b2a0aa2 100644 --- a/scripts/check-edge-functions.mjs +++ b/scripts/check-edge-functions.mjs @@ -8,7 +8,7 @@ const denoMajor = denoVersionMatch ? Number(denoVersionMatch[1]) : null; const hasDeno = deno.status === 0 && denoMajor !== null && denoMajor >= 2; if (!hasDeno) { - const found = deno.status === 0 ? (denoVersionLine || "unknown version") : "not installed"; + const found = deno.status === 0 ? denoVersionLine || "unknown version" : "not installed"; if (process.env.CI) { console.error( `[check:edge:functions] Deno v2.x is required in CI (found: ${found}). Install Deno and rerun this check.`, diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index aab5c30bd3..df38231bd4 100644 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -47,7 +47,10 @@ export function checkNodeRuntime(version: string, expectedMajor = 24): RuntimeCh return runtimeResult("Node", version, expectedMajor); } -export function checkNpmRuntime(userAgent = process.env.npm_config_user_agent ?? "", expectedMajor = 11): RuntimeCheckResult { +export function checkNpmRuntime( + userAgent = process.env.npm_config_user_agent ?? "", + expectedMajor = 11, +): RuntimeCheckResult { if (!userAgent) { return { ok: true, diff --git a/scripts/classify-documents.ts b/scripts/classify-documents.ts index bed6db32aa..1954f676f6 100644 --- a/scripts/classify-documents.ts +++ b/scripts/classify-documents.ts @@ -130,17 +130,30 @@ async function writeClassification( .eq("status", "indexed"); if (documentError) throw new Error(documentError.message); + // Delete all previously generated labels for this document (all types) const { error: deleteError } = await supabase .from("document_labels") .delete() .eq("document_id", document.id) .eq("source", "generated") - .eq("label_type", "site"); + .in("label_type", ["site", "document_type", "population", "topic", "setting", "service", "workflow"]); if (deleteError) throw new Error(deleteError.message); - const generatedLabels = classification.labels.filter( - (label) => label.label_type === "site" && label.confidence >= 0.75, + // Write site labels (confident only, >= 0.75) + const siteLabels = classification.labels.filter((label) => label.label_type === "site" && label.confidence >= 0.75); + + // Write document_type labels (any confidence >= 0.5 — so even needs_review types are captured) + const typeLabels = classification.labels.filter( + (label) => label.label_type === "document_type" && label.confidence >= 0.5, + ); + + // Write all secondary facet labels (population, topic, setting, service, workflow) + const secondaryLabels = classification.labels.filter( + (label) => + ["population", "topic", "setting", "service", "workflow"].includes(label.label_type) && label.confidence >= 0.5, ); + + const generatedLabels = [...siteLabels, ...typeLabels, ...secondaryLabels]; if (!generatedLabels.length) return; const { error: labelError } = await supabase.from("document_labels").upsert( diff --git a/scripts/enrich-documents.ts b/scripts/enrich-documents.ts index c4c07fc65b..cfe0de9efd 100644 --- a/scripts/enrich-documents.ts +++ b/scripts/enrich-documents.ts @@ -312,12 +312,7 @@ async function classifyExistingImages(supabase: SupabaseAdmin, documentId: strin width: image.width, height: image.height, sourceKind: image.source_kind as - | "embedded" - | "table_crop" - | "diagram_crop" - | "page_region" - | "fallback" - | undefined, + "embedded" | "table_crop" | "diagram_crop" | "page_region" | "fallback" | undefined, }, }); diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index ae28fa62c1..f65312c6de 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -140,7 +140,8 @@ function parseArgs(argv: string[]): EvalArgs { if (token === "--limit") args.limit = Number.parseInt(value, 10); if (token === "--query") args.query = value; if (token === "--mode") { - if (!["combined", "quality", "latency"].includes(value)) throw new Error("--mode must be combined, quality, or latency."); + if (!["combined", "quality", "latency"].includes(value)) + throw new Error("--mode must be combined, quality, or latency."); args.mode = value as EvalArgs["mode"]; } if (token === "--case-timeout-ms") args.caseTimeoutMs = Number.parseInt(value, 10); @@ -151,7 +152,8 @@ function parseArgs(argv: string[]): EvalArgs { if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit <= 0)) { throw new Error("--limit must be a positive integer."); } - if (!Number.isInteger(args.caseTimeoutMs) || args.caseTimeoutMs < 0) throw new Error("--case-timeout-ms must be >= 0."); + if (!Number.isInteger(args.caseTimeoutMs) || args.caseTimeoutMs < 0) + throw new Error("--case-timeout-ms must be >= 0."); if (!Number.isInteger(args.p90BudgetMs) || args.p90BudgetMs <= 0) throw new Error("--p90-ms must be positive."); if (!Number.isInteger(args.p50BudgetMs) || args.p50BudgetMs <= 0) throw new Error("--p50-ms must be positive."); @@ -580,7 +582,10 @@ function caseNeedsVisualUnits(testCase: GoldenRetrievalCase) { ); } -async function visualReadinessWarnings(supabase: Awaited>, cases: GoldenRetrievalCase[]) { +async function visualReadinessWarnings( + supabase: Awaited>, + cases: GoldenRetrievalCase[], +) { const visualCases = cases.filter(caseNeedsVisualUnits); if (visualCases.length === 0) return [] as string[]; const { count, error } = await supabase @@ -647,7 +652,10 @@ function latencyFailuresForCase(result: Pick(promise: Promise, timeoutMs: number): Promise<{ timedOut: false; value: T } | { timedOut: true }> { +async function withCaseTimeout( + promise: Promise, + timeoutMs: number, +): Promise<{ timedOut: false; value: T } | { timedOut: true }> { if (timeoutMs <= 0) return { timedOut: false, value: await promise }; let timeout: NodeJS.Timeout | undefined; try { @@ -701,24 +709,23 @@ async function main() { skipCache: args.mode !== "latency", }); const searchOutcome = await withCaseTimeout(searchPromise, args.caseTimeoutMs); - const search = - searchOutcome.timedOut - ? { - results: [] as SearchResult[], - telemetry: { - query_class: testCase.expectedQueryClass, - retrieval_strategy: "timeout", - embedding_skipped: false, - text_fast_path_latency_ms: 0, - embedding_latency_ms: 0, - supabase_rpc_latency_ms: args.caseTimeoutMs, - rerank_latency_ms: 0, - retrieval_layer_counts: {}, - retrieval_layer_top_scores: {}, - retrieval_layer_latencies_ms: {}, - }, - } - : searchOutcome.value; + const search = searchOutcome.timedOut + ? { + results: [] as SearchResult[], + telemetry: { + query_class: testCase.expectedQueryClass, + retrieval_strategy: "timeout", + embedding_skipped: false, + text_fast_path_latency_ms: 0, + embedding_latency_ms: 0, + supabase_rpc_latency_ms: args.caseTimeoutMs, + rerank_latency_ms: 0, + retrieval_layer_counts: {}, + retrieval_layer_top_scores: {}, + retrieval_layer_latencies_ms: {}, + }, + } + : searchOutcome.value; const latencyMs = searchOutcome.timedOut ? args.caseTimeoutMs : latencyFromTelemetry(search.telemetry) || Date.now() - startedAt; @@ -780,8 +787,7 @@ async function main() { const qualityThresholdFailed = args.mode !== "latency" && summary.failed_cases.length > 0; const latencyThresholdFailed = args.mode === "latency" && (summary.latency_failed_cases.length > 0 || latencyThresholdFailures.length > 0); - if (args.failOnThreshold && (qualityThresholdFailed || latencyThresholdFailed)) - process.exit(1); + if (args.failOnThreshold && (qualityThresholdFailed || latencyThresholdFailed)) process.exit(1); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/scripts/playwright-base-url.ts b/scripts/playwright-base-url.ts index 314d8b7abc..d0c3ac75e7 100644 --- a/scripts/playwright-base-url.ts +++ b/scripts/playwright-base-url.ts @@ -8,7 +8,7 @@ const localUrlPattern = /^http:\/\/localhost:\d+$/; const identityScript = ` const http = require("node:http"); const url = process.argv[1] + "/api/local-project-id"; -const request = http.get(url, { timeout: 5000 }, (response) => { +const request = http.get(url, { timeout: 15000 }, (response) => { let body = ""; response.setEncoding("utf8"); response.on("data", (chunk) => { body += chunk; }); @@ -21,12 +21,33 @@ request.on("timeout", () => { request.destroy(); process.exit(3); }); request.on("error", () => process.exit(4)); `; +function sleepSync(ms: number) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + function verifyLocalProjectIdentity(baseUrl: string) { - const output = execFileSync(process.execPath, ["-e", identityScript, baseUrl], { - cwd: projectRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "inherit"], - }).trim(); + let lastError: unknown = null; + let output = ""; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + output = execFileSync(process.execPath, ["-e", identityScript, baseUrl], { + cwd: projectRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + timeout: 20_000, + }).trim(); + break; + } catch (error) { + lastError = error; + if (attempt < 2) sleepSync(750); + } + } + + if (!output) { + throw lastError instanceof Error ? lastError : new Error(`Could not verify local project identity: ${baseUrl}`); + } + const payload = JSON.parse(output) as { appName?: string; projectId?: string; diff --git a/scripts/profile-retrieval-rpcs.ts b/scripts/profile-retrieval-rpcs.ts index f59951ba4e..ecdb7b76a0 100644 --- a/scripts/profile-retrieval-rpcs.ts +++ b/scripts/profile-retrieval-rpcs.ts @@ -50,8 +50,16 @@ function parseArgs(argv: string[]): Args { if (token === "--owner-email") args.ownerEmail = value; if (token === "--owner-id") args.ownerId = value; if (token === "--match-count") args.matchCount = Number.parseInt(value, 10); - if (token === "--document-ids") args.documentIds = value.split(",").map((item) => item.trim()).filter(Boolean); - if (token === "--rpc") args.rpcs = value.split(",").map((item) => item.trim()).filter(Boolean); + if (token === "--document-ids") + args.documentIds = value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + if (token === "--rpc") + args.rpcs = value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); if (token === "--output") args.output = value; } @@ -95,7 +103,8 @@ async function main() { }; const json = JSON.stringify(payload, null, 2); if (args.output) { - const outputPath = args.output.includes("\\") || args.output.includes("/") ? args.output : join(process.cwd(), args.output); + const outputPath = + args.output.includes("\\") || args.output.includes("/") ? args.output : join(process.cwd(), args.output); await writeFile(outputPath, json, "utf8"); console.log(`Retrieval RPC profile written to ${outputPath}`); } else { diff --git a/scripts/reindex.ts b/scripts/reindex.ts index 662cf36b18..88bf700eb8 100644 --- a/scripts/reindex.ts +++ b/scripts/reindex.ts @@ -93,9 +93,8 @@ async function main() { console.log("=== Reindex Pipeline ===\n"); // Step 1 – Confirm target project - const { checkSupabaseProjectConfig, expectedSupabaseProject, formatSupabaseProjectCheck } = await import( - "@/lib/supabase/project" - ); + const { checkSupabaseProjectConfig, expectedSupabaseProject, formatSupabaseProjectCheck } = + await import("@/lib/supabase/project"); const projectCheck = checkSupabaseProjectConfig({ NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, SUPABASE_PROJECT_REF: process.env.SUPABASE_PROJECT_REF, @@ -129,45 +128,44 @@ async function main() { processingDocuments, failedDocuments, indexedDocuments, - ] = - await Promise.all([ - safeCount( - "jobs_pending", - supabase.from("ingestion_jobs").select("id", { count: "exact", head: true }).eq("status", "pending"), - ), - safeCount( - "jobs_processing", - supabase.from("ingestion_jobs").select("id", { count: "exact", head: true }).eq("status", "processing"), - ), - safeCount( - "jobs_failed", - supabase.from("ingestion_jobs").select("id", { count: "exact", head: true }).eq("status", "failed"), - ), - safeCount( - "jobs_stale", - supabase - .from("ingestion_jobs") - .select("id", { count: "exact", head: true }) - .eq("status", "processing") - .or(`locked_at.is.null,locked_at.lt.${staleCutoff}`), - ), - safeCount( - "documents_queued", - supabase.from("documents").select("id", { count: "exact", head: true }).eq("status", "queued"), - ), - safeCount( - "documents_processing", - supabase.from("documents").select("id", { count: "exact", head: true }).eq("status", "processing"), - ), - safeCount( - "documents_failed", - supabase.from("documents").select("id", { count: "exact", head: true }).eq("status", "failed"), - ), - safeCount( - "documents_indexed", - supabase.from("documents").select("id", { count: "exact", head: true }).eq("status", "indexed"), - ), - ]); + ] = await Promise.all([ + safeCount( + "jobs_pending", + supabase.from("ingestion_jobs").select("id", { count: "exact", head: true }).eq("status", "pending"), + ), + safeCount( + "jobs_processing", + supabase.from("ingestion_jobs").select("id", { count: "exact", head: true }).eq("status", "processing"), + ), + safeCount( + "jobs_failed", + supabase.from("ingestion_jobs").select("id", { count: "exact", head: true }).eq("status", "failed"), + ), + safeCount( + "jobs_stale", + supabase + .from("ingestion_jobs") + .select("id", { count: "exact", head: true }) + .eq("status", "processing") + .or(`locked_at.is.null,locked_at.lt.${staleCutoff}`), + ), + safeCount( + "documents_queued", + supabase.from("documents").select("id", { count: "exact", head: true }).eq("status", "queued"), + ), + safeCount( + "documents_processing", + supabase.from("documents").select("id", { count: "exact", head: true }).eq("status", "processing"), + ), + safeCount( + "documents_failed", + supabase.from("documents").select("id", { count: "exact", head: true }).eq("status", "failed"), + ), + safeCount( + "documents_indexed", + supabase.from("documents").select("id", { count: "exact", head: true }).eq("status", "indexed"), + ), + ]); const countItems = [ pendingJobs, @@ -376,9 +374,7 @@ async function main() { } if (hasActiveProcessingJobs) { - console.log( - "\n Active processing jobs detected; skipping worker run to avoid concurrency spikes.", - ); + console.log("\n Active processing jobs detected; skipping worker run to avoid concurrency spikes."); continue; } @@ -393,7 +389,9 @@ async function main() { } } - console.log(`\nReached maximum rounds (${args.maxRounds}). Queue did not clear; re-run with a higher --max-rounds or inspect stale jobs first.`); + console.log( + `\nReached maximum rounds (${args.maxRounds}). Queue did not clear; re-run with a higher --max-rounds or inspect stale jobs first.`, + ); process.exitCode = 1; } diff --git a/scripts/retrieval-health.ts b/scripts/retrieval-health.ts index f2fc23626a..5dd2677beb 100644 --- a/scripts/retrieval-health.ts +++ b/scripts/retrieval-health.ts @@ -54,25 +54,28 @@ async function main() { .limit(limit); if (ownerId) logsQuery = logsQuery.eq("owner_id", ownerId); - const [{ data: logsData, error: logsError }, { data: qualityData, error: qualityError }, { data: visualUnitsData, error: visualUnitsError }] = - await Promise.all([ - logsQuery, - supabase.from("document_index_quality").select("document_id,metrics,issues").limit(5000), - supabase - .from("document_index_units") - .select("document_id,unit_type,source_image_id") - .in("unit_type", [ - "visual_summary", - "flowchart_step", - "diagram_decision", - "risk_matrix_cell", - "medication_chart_row", - "chart_finding", - "visual_askable_question", - "table_threshold", - ]) - .limit(5000), - ]); + const [ + { data: logsData, error: logsError }, + { data: qualityData, error: qualityError }, + { data: visualUnitsData, error: visualUnitsError }, + ] = await Promise.all([ + logsQuery, + supabase.from("document_index_quality").select("document_id,metrics,issues").limit(5000), + supabase + .from("document_index_units") + .select("document_id,unit_type,source_image_id") + .in("unit_type", [ + "visual_summary", + "flowchart_step", + "diagram_decision", + "risk_matrix_cell", + "medication_chart_row", + "chart_finding", + "visual_askable_question", + "table_threshold", + ]) + .limit(5000), + ]); if (logsError) throw new Error(logsError.message); if (qualityError) throw new Error(qualityError.message); @@ -105,9 +108,7 @@ async function main() { const missingSourceImageCount = logs.filter( (row) => row.metadata?.source_image_required === true && row.metadata?.source_image_satisfied !== true, ).length; - const documentDiversity = logs - .map((row) => row.selected_document_ids?.length ?? 0) - .filter((count) => count > 0); + const documentDiversity = logs.map((row) => row.selected_document_ids?.length ?? 0).filter((count) => count > 0); const visualUnitTypes = countBy((visualUnitsData ?? []).map((row) => String(row.unit_type))); const sparseVisualDocs = (qualityData ?? []).filter((row) => { const issues = Array.isArray(row.issues) ? row.issues.map(String) : []; diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index 0a8a7c3f6d..7969209788 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -5,6 +5,11 @@ import { upsertDocumentDeepMemory } from "@/lib/deep-memory"; import { jsonError } from "@/lib/http"; import { checkIngestionMutationSafety, ingestionMutationSafetyPayload } from "@/lib/ingestion-mutation-safety"; import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + committedIndexGeneration, + isAtomicReindexCandidate, + isCommittedGenerationMetadata, +} from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -34,6 +39,16 @@ type ReindexImage = { metadata?: Record | null; }; +function committedReindexRows(document: { metadata?: unknown }, rows: T[]) { + const committedGeneration = committedIndexGeneration(document.metadata); + return rows.filter((row) => + isCommittedGenerationMetadata({ + rowMetadata: row.metadata, + committedGeneration, + }), + ); +} + async function readMode(request: Request) { try { const body = await request.json(); @@ -78,7 +93,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const { data: document, error: documentError } = await supabase .from("documents") - .select("id,owner_id,title,file_name,source_path,import_batch_id,metadata") + .select("id,owner_id,title,file_name,source_path,import_batch_id,status,metadata") .eq("id", id) .eq("owner_id", user.id) .maybeSingle(); @@ -90,7 +105,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: supabase, documentIds: [id], action: "Reindex", - checkActiveJobs: mode !== "enrichment", + checkActiveJobs: true, staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, }); if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); @@ -112,24 +127,27 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: }), ]); - chunks.sort((a, b) => Number(a.chunk_index ?? 0) - Number(b.chunk_index ?? 0)); - images.sort((a, b) => Number(b.clinical_relevance_score ?? 0) - Number(a.clinical_relevance_score ?? 0)); + const committedChunks = committedReindexRows(document, chunks); + const committedImages = committedReindexRows(document, images); - if (!chunks.length) { + committedChunks.sort((a, b) => Number(a.chunk_index ?? 0) - Number(b.chunk_index ?? 0)); + committedImages.sort((a, b) => Number(b.clinical_relevance_score ?? 0) - Number(a.clinical_relevance_score ?? 0)); + + if (!committedChunks.length) { return NextResponse.json({ error: "Document has no indexed chunks to enrich." }, { status: 400 }); } const enrichment = await upsertDocumentEnrichment({ supabase, document, - chunks, - images, + chunks: committedChunks, + images: committedImages, }); const deepMemory = await upsertDocumentDeepMemory({ supabase, document, - chunks, - images, + chunks: committedChunks, + images: committedImages, summary: enrichment.summary.summary, }); return NextResponse.json({ @@ -143,12 +161,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: }); } - const { error: resetError } = await supabase.rpc("reset_document_index", { p_document_id: id }); - if (resetError) throw new Error(resetError.message); - + const atomicReindex = isAtomicReindexCandidate(document); const { error: updateError } = await supabase .from("documents") - .update({ status: "queued", error_message: null, page_count: 0, chunk_count: 0, image_count: 0 }) + .update( + atomicReindex + ? { error_message: null } + : { status: "queued", error_message: null, page_count: 0, chunk_count: 0, image_count: 0 }, + ) .eq("id", id) .eq("owner_id", user.id); if (updateError) throw new Error(updateError.message); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 3fc34fcffa..95799522de 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -4,6 +4,7 @@ import { getDemoDocumentPayload } from "@/lib/demo-data"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; 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"; @@ -119,6 +120,11 @@ function withImageTableMetadata(image: T) { }; } +function committedRows(document: { metadata?: unknown }, rows: T[]) { + const committedGeneration = committedIndexGeneration(document.metadata); + return rows.filter((row) => isCommittedGenerationMetadata({ rowMetadata: row.metadata, committedGeneration })); +} + function storageWarningsFrom(error: unknown, label: string) { const message = error && typeof error === "object" && "message" in error ? String(error.message) : "Storage cleanup failed."; @@ -305,7 +311,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: .maybeSingle(); if (selectedChunkError) throw new Error(selectedChunkError.message); - selectedChunk = data ?? null; + selectedChunk = data && committedRows(document, [data]).length > 0 ? data : null; } const effectivePage = selectedChunk?.page_number ?? requestedPage; @@ -373,9 +379,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: summary: summaryResult.data ?? null, }, pages: pages ?? [], - images: (images ?? []).map(withImageTableMetadata), - tableFacts: tableFactsResult.data ?? [], - chunks: chunks ?? [], + images: committedRows(document, images ?? []).map(withImageTableMetadata), + tableFacts: committedRows(document, tableFactsResult.data ?? []), + chunks: committedRows(document, chunks ?? []), pageWindow: { from: pageWindow.from, to: pageWindow.to, diff --git a/src/app/api/documents/[id]/search/route.ts b/src/app/api/documents/[id]/search/route.ts index d7a601594e..159cdd5942 100644 --- a/src/app/api/documents/[id]/search/route.ts +++ b/src/app/api/documents/[id]/search/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { demoChunks, getDemoDocument } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; +import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -16,6 +17,8 @@ type DocumentChunkSearchRow = { image_ids: string[] | null; text_rank?: number | null; trigram_score?: number | null; + metadata?: Record | null; + index_generation_id?: string | null; }; const maxSearchTerms = 8; @@ -139,6 +142,10 @@ function resultFromChunk(row: DocumentChunkSearchRow, query: string, terms: stri }; } +function generationMetadataForRow(row: DocumentChunkSearchRow) { + return row.index_generation_id ? { index_generation_id: row.index_generation_id } : row.metadata; +} + export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; @@ -174,13 +181,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const user = await requireAuthenticatedUser(request, supabase); const { data: document, error: documentError } = await supabase .from("documents") - .select("id") + .select("id,metadata") .eq("id", id) .eq("owner_id", user.id) .maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); + const committedGeneration = committedIndexGeneration(document.metadata); const { data: rpcData, error: rpcError } = await supabase.rpc("search_document_chunks", { p_document_id: id, @@ -191,6 +199,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: if (!rpcError) { const results = ((rpcData ?? []) as DocumentChunkSearchRow[]) + .filter((row) => + isCommittedGenerationMetadata({ + rowMetadata: generationMetadataForRow(row), + committedGeneration, + }), + ) .map((row) => resultFromChunk(row, query, terms)) .filter((result) => result.score > 0) .sort( @@ -219,7 +233,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const queryBuilder = supabase .from("document_chunks") - .select("id,page_number,chunk_index,section_heading,content,image_ids") + .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata,index_generation_id") .eq("document_id", id) .order("chunk_index", { ascending: true }) .limit(Math.min(maxSearchLimit * 3, Math.max(limit * 3, limit))); @@ -228,11 +242,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: if (error) throw new Error(error.message); const importantTerms = importantTermsFor(terms); - const candidateRows = ((data ?? []) as DocumentChunkSearchRow[]).filter((row) => { + const committedData = ((data ?? []) as DocumentChunkSearchRow[]).filter((row) => + isCommittedGenerationMetadata({ + rowMetadata: generationMetadataForRow(row), + committedGeneration, + }), + ); + const candidateRows = committedData.filter((row) => { if (importantTerms.length <= 1) return true; return importantTerms.every((term) => coveredTermsFor(row, [term]).length > 0); }); - const fallbackRows = candidateRows.length ? candidateRows : ((data ?? []) as DocumentChunkSearchRow[]); + const fallbackRows = candidateRows.length ? candidateRows : committedData; const results = fallbackRows .map((row) => resultFromChunk(row, query, terms)) .filter((result) => { diff --git a/src/app/api/documents/[id]/table-facts/route.ts b/src/app/api/documents/[id]/table-facts/route.ts index db6cc0f9bf..3eb1166b0a 100644 --- a/src/app/api/documents/[id]/table-facts/route.ts +++ b/src/app/api/documents/[id]/table-facts/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { invalidateRagCachesForOwner } 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 { tableReviewMetadata, tableReviewSchema } from "@/lib/table-review"; @@ -17,19 +18,19 @@ function metadataRecord(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record) } : {}; } -async function assertDocumentOwner(args: { +async function loadOwnedDocument(args: { supabase: ReturnType; documentId: string; ownerId: string; }) { const { data, error } = await args.supabase .from("documents") - .select("id") + .select("id,metadata") .eq("id", args.documentId) .eq("owner_id", args.ownerId) .maybeSingle(); if (error) throw new Error(error.message); - return Boolean(data); + return data; } export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { @@ -39,9 +40,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); - if (!(await assertDocumentOwner({ supabase, documentId: id, ownerId: user.id }))) { + const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id }); + if (!document) { return NextResponse.json({ error: "Document not found." }, { status: 404 }); } + const committedGeneration = committedIndexGeneration(document.metadata); const { data, error } = await supabase .from("document_table_facts") @@ -50,7 +53,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: .order("page_number", { ascending: true }) .order("created_at", { ascending: true }); if (error) throw new Error(error.message); - return NextResponse.json({ tableFacts: data ?? [] }); + return NextResponse.json({ + tableFacts: (data ?? []).filter((fact) => + isCommittedGenerationMetadata({ rowMetadata: fact.metadata, committedGeneration }), + ), + }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); @@ -67,9 +74,11 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); - if (!(await assertDocumentOwner({ supabase, documentId: id, ownerId: user.id }))) { + const document = await loadOwnedDocument({ supabase, documentId: id, ownerId: user.id }); + if (!document) { return NextResponse.json({ error: "Document not found." }, { status: 404 }); } + const committedGeneration = committedIndexGeneration(document.metadata); const { data: fact, error: factError } = await supabase .from("document_table_facts") @@ -80,6 +89,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .maybeSingle(); if (factError) throw new Error(factError.message); if (!fact) return NextResponse.json({ error: "Table fact not found." }, { status: 404 }); + if (!isCommittedGenerationMetadata({ rowMetadata: fact.metadata, committedGeneration })) { + return NextResponse.json({ error: "Table fact not found." }, { status: 404 }); + } const reviewMetadata = tableReviewMetadata({ reviewClass: parsed.data.reviewClass, @@ -105,6 +117,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .eq("document_id", id) .maybeSingle(); if (image) { + if (!isCommittedGenerationMetadata({ rowMetadata: image.metadata, committedGeneration })) { + return NextResponse.json({ error: "Table fact not found." }, { status: 404 }); + } await supabase .from("document_images") .update({ diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 324a890ea4..f1319a9f59 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -7,6 +7,11 @@ import { jsonError, PublicApiError } from "@/lib/http"; import { checkIngestionMutationSafety, ingestionMutationSafetyPayload } from "@/lib/ingestion-mutation-safety"; import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { invalidateRagCachesForOwner } from "@/lib/rag"; +import { + committedIndexGeneration, + isAtomicReindexCandidate, + isCommittedGenerationMetadata, +} from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -41,6 +46,16 @@ type ReindexImage = { metadata?: Record | null; }; +function committedReindexRows(document: { metadata?: unknown }, rows: T[]) { + const committedGeneration = committedIndexGeneration(document.metadata); + return rows.filter((row) => + isCommittedGenerationMetadata({ + rowMetadata: row.metadata, + committedGeneration, + }), + ); +} + async function selectRowsInPages(args: { supabase: ReturnType; table: "document_chunks" | "document_images"; @@ -86,7 +101,7 @@ export async function POST(request: Request) { supabase, documentIds: documents.map((document) => document.id), action: "Bulk reindex", - checkActiveJobs: parsed.data.mode !== "enrichment", + checkActiveJobs: true, staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, }); if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); @@ -121,15 +136,24 @@ export async function POST(request: Request) { searchableOnly: true, }), ]); - if (!chunks.length) throw new Error("Document has no indexed chunks to enrich."); - chunks.sort((a, b) => Number(a.chunk_index ?? 0) - Number(b.chunk_index ?? 0)); - images.sort((a, b) => Number(b.clinical_relevance_score ?? 0) - Number(a.clinical_relevance_score ?? 0)); - const enrichment = await upsertDocumentEnrichment({ supabase, document, chunks, images }); + const committedChunks = committedReindexRows(document, chunks); + const committedImages = committedReindexRows(document, images); + if (!committedChunks.length) throw new Error("Document has no indexed chunks to enrich."); + committedChunks.sort((a, b) => Number(a.chunk_index ?? 0) - Number(b.chunk_index ?? 0)); + committedImages.sort( + (a, b) => Number(b.clinical_relevance_score ?? 0) - Number(a.clinical_relevance_score ?? 0), + ); + const enrichment = await upsertDocumentEnrichment({ + supabase, + document, + chunks: committedChunks, + images: committedImages, + }); const memory = await upsertDocumentDeepMemory({ supabase, document, - chunks, - images, + chunks: committedChunks, + images: committedImages, summary: enrichment.summary.summary, }); results.push({ @@ -141,11 +165,14 @@ export async function POST(request: Request) { continue; } - const { error: resetError } = await supabase.rpc("reset_document_index", { p_document_id: document.id }); - if (resetError) throw new Error(resetError.message); + const atomicReindex = isAtomicReindexCandidate(document); const { error: updateError } = await supabase .from("documents") - .update({ status: "queued", error_message: null, page_count: 0, chunk_count: 0, image_count: 0 }) + .update( + atomicReindex + ? { error_message: null } + : { status: "queued", error_message: null, page_count: 0, chunk_count: 0, image_count: 0 }, + ) .eq("id", document.id) .eq("owner_id", user.id); if (updateError) throw new Error(updateError.message); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 1ee0b56d5d..dc910b2c29 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -194,13 +194,6 @@ export async function GET(request: Request) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); } - if (error instanceof Error && error.message.includes("Missing server environment")) { - return NextResponse.json({ - documents: demoDocuments, - demoMode: true, - error: "Server environment is not configured; demo data is being served.", - }); - } return jsonError(error); } } diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index 3f1a6d254d..b061aaf8a2 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -4,6 +4,7 @@ import { getDemoImage } from "@/lib/demo-data"; import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; +import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -33,7 +34,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: const user = await requireAuthenticatedUser(_request, supabase); const { data: image, error } = await supabase .from("document_images") - .select("document_id,storage_path,mime_type,caption") + .select("document_id,storage_path,mime_type,caption,metadata") .eq("id", id) .maybeSingle(); @@ -42,13 +43,21 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: const { data: document, error: documentError } = await supabase .from("documents") - .select("id") + .select("id,metadata") .eq("id", image.document_id) .eq("owner_id", user.id) .maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Image not found." }, { status: 404 }); + if ( + !isCommittedGenerationMetadata({ + rowMetadata: image.metadata, + committedGeneration: committedIndexGeneration(document.metadata), + }) + ) { + return NextResponse.json({ error: "Image not found." }, { status: 404 }); + } const signed = await supabase.storage .from(env.SUPABASE_IMAGE_BUCKET) diff --git a/src/app/api/ingestion/quality/route.ts b/src/app/api/ingestion/quality/route.ts index 74ad758d1f..f99c77cb00 100644 --- a/src/app/api/ingestion/quality/route.ts +++ b/src/app/api/ingestion/quality/route.ts @@ -8,12 +8,7 @@ export const runtime = "nodejs"; type Severity = "danger" | "warning" | "info"; type ReviewType = - | "failed_ocr" - | "low_extraction_confidence" - | "missing_tables" - | "image_only_pages" - | "failed_job" - | "manual_review"; + "failed_ocr" | "low_extraction_confidence" | "missing_tables" | "image_only_pages" | "failed_job" | "manual_review"; type DocumentRow = { id: string; @@ -141,10 +136,12 @@ function buildReviewItems(args: { const pagesByDocument = new Map(); const imagesByDocument = new Map(); - for (const row of args.jobs) jobsByDocument.set(row.document_id, [...(jobsByDocument.get(row.document_id) ?? []), row]); + for (const row of args.jobs) + jobsByDocument.set(row.document_id, [...(jobsByDocument.get(row.document_id) ?? []), row]); for (const row of args.stages) stagesByDocument.set(row.document_id, [...(stagesByDocument.get(row.document_id) ?? []), row]); - for (const row of args.pages) pagesByDocument.set(row.document_id, [...(pagesByDocument.get(row.document_id) ?? []), row]); + for (const row of args.pages) + pagesByDocument.set(row.document_id, [...(pagesByDocument.get(row.document_id) ?? []), row]); for (const row of args.images) imagesByDocument.set(row.document_id, [...(imagesByDocument.get(row.document_id) ?? []), row]); @@ -206,13 +203,15 @@ function buildReviewItems(args: { type: "failed_ocr", severity: "danger", title: "OCR or extraction failed", - detail: failedOcrStage?.error_message || ocrWarning || "OCR/extraction warnings were recorded for this document.", + detail: + failedOcrStage?.error_message || ocrWarning || "OCR/extraction warnings were recorded for this document.", jobId: failedOcrStage?.job_id ?? null, qualityScore: normalizedQualityScore, extractionQuality, reasons: unique([failedOcrStage?.stage_name, failedOcrStage?.error_message, ocrWarning]), metrics, - updatedAt: failedOcrStage?.finished_at ?? failedOcrStage?.started_at ?? quality?.updated_at ?? document.updated_at, + updatedAt: + failedOcrStage?.finished_at ?? failedOcrStage?.started_at ?? quality?.updated_at ?? document.updated_at, }); } @@ -230,7 +229,10 @@ function buildReviewItems(args: { jobId: null, qualityScore: normalizedQualityScore, extractionQuality, - reasons: unique(["low page text coverage", ...issues.filter((issue) => includesAny(issue, ["text coverage", "text volume"]))]), + reasons: unique([ + "low page text coverage", + ...issues.filter((issue) => includesAny(issue, ["text coverage", "text volume"])), + ]), metrics, updatedAt: quality?.updated_at ?? document.updated_at, }); @@ -242,7 +244,9 @@ function buildReviewItems(args: { type: "missing_tables", severity: "warning", title: "Table extraction needs review", - detail: tableIssue || `${tablesWithoutRows.length} detected table image${tablesWithoutRows.length === 1 ? "" : "s"} have no structured rows.`, + detail: + tableIssue || + `${tablesWithoutRows.length} detected table image${tablesWithoutRows.length === 1 ? "" : "s"} have no structured rows.`, jobId: null, qualityScore: normalizedQualityScore, extractionQuality, @@ -256,12 +260,17 @@ function buildReviewItems(args: { extractionQuality === "poor" || extractionQuality === "partial" || (normalizedQualityScore !== null && normalizedQualityScore < 0.72) || - issues.some((issue) => includesAny(issue, ["low structured visual extraction confidence", "low visual unit coverage"])) + issues.some((issue) => + includesAny(issue, ["low structured visual extraction confidence", "low visual unit coverage"]), + ) ) { pushItem(document, { id: `low_extraction_confidence:${document.id}`, type: "low_extraction_confidence", - severity: extractionQuality === "poor" || (normalizedQualityScore !== null && normalizedQualityScore < 0.52) ? "danger" : "warning", + severity: + extractionQuality === "poor" || (normalizedQualityScore !== null && normalizedQualityScore < 0.52) + ? "danger" + : "warning", title: "Low extraction confidence", detail: `Extraction is ${extractionQuality}; index quality is ${normalizedQualityScore === null ? "unknown" : normalizedQualityScore.toFixed(2)}.`, jobId: null, @@ -290,7 +299,9 @@ function buildReviewItems(args: { } } - return items.sort((a, b) => itemPriority(a) - itemPriority(b) || (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")); + return items.sort( + (a, b) => itemPriority(a) - itemPriority(b) || (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""), + ); } export async function GET(request: Request) { @@ -326,7 +337,9 @@ export async function GET(request: Request) { .limit(200), supabase .from("ingestion_job_stages") - .select("id,document_id,job_id,stage_name,stage_status,error_message,metadata,artifact_counts,finished_at,started_at") + .select( + "id,document_id,job_id,stage_name,stage_status,error_message,metadata,artifact_counts,finished_at,started_at", + ) .in("document_id", documentIds) .order("started_at", { ascending: false }) .limit(300), diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx new file mode 100644 index 0000000000..2f7fd01fd7 --- /dev/null +++ b/src/app/applications/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ApplicationsLauncherPage } from "@/components/applications-launcher-page"; + +export const metadata: Metadata = { + title: "Applications - Clinical KB", + description: "Launch Clinical KB applications, workflows, and connected clinical tools.", +}; + +export default function ApplicationsRoute() { + return ; +} diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx deleted file mode 100644 index a6a865122e..0000000000 --- a/src/app/home/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Metadata } from "next"; - -import { UserHomeProfilePage } from "@/components/user-home-profile"; - -export const metadata: Metadata = { - title: "Home - Clinical KB", - description: "Logged-in profile home and settings workspace for Clinical KB users.", -}; - -export default function HomeProfileRoute() { - return ; -} diff --git a/src/app/medications/[slug]/page.tsx b/src/app/medications/[slug]/page.tsx new file mode 100644 index 0000000000..754c5f7f2f --- /dev/null +++ b/src/app/medications/[slug]/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { AcamprosateMedicationPage } from "@/components/clinical-dashboard/medication-prescribing-workspace"; + +type MedicationPageProps = { + params: Promise<{ + slug: string; + }>; +}; + +export function generateStaticParams() { + return [{ slug: "acamprosate" }]; +} + +export async function generateMetadata({ params }: MedicationPageProps): Promise { + const { slug } = await params; + if (slug !== "acamprosate") { + return { + title: "Medication | Clinical KB", + }; + } + + return { + title: "Acamprosate | Clinical KB", + description: "Acamprosate prescribing summary, dosing, safety checks, monitoring, access, and provenance.", + }; +} + +export default async function MedicationPage({ params }: MedicationPageProps) { + const { slug } = await params; + if (slug !== "acamprosate") notFound(); + + return ; +} diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx new file mode 100644 index 0000000000..64432c1b99 --- /dev/null +++ b/src/app/medications/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function MedicationsIndexPage() { + redirect("/?mode=prescribing"); +} diff --git a/src/app/mockups/clinical-command-popup/page.tsx b/src/app/mockups/clinical-command-popup/page.tsx new file mode 100644 index 0000000000..6dbe1b8a8b --- /dev/null +++ b/src/app/mockups/clinical-command-popup/page.tsx @@ -0,0 +1,555 @@ +"use client"; + +import { + Activity, + Bookmark, + Check, + CheckSquare, + ChevronDown, + ClipboardCheck, + Copy, + ExternalLink, + FileText, + Grid2X2, + Menu, + Plus, + Search, + ShieldCheck, + ShieldIcon, + Square, + Stethoscope, + X, +} from "lucide-react"; +import { useMemo, useState, type ReactNode } from "react"; + +type FindingId = "fbc" | "myocarditis" | "metabolic" | "renal" | "toxicity"; +type TabId = "safety" | "monitor" | "table" | "checklist"; + +const findings: Array<{ + id: FindingId; + priority: "High" | "Med" | "Low"; + title: string; + subtitle: string; + source: string; + accent: string; + icon: typeof ShieldIcon; + detail: string; + facts: Array<[string, string]>; +}> = [ + { + id: "fbc", + priority: "High", + title: "FBC/ANC", + subtitle: "Record baseline result", + source: "Source 2", + accent: "text-red-600 border-red-200 bg-red-50", + icon: ShieldIcon, + detail: "A baseline FBC/ANC should be recorded prior to starting lithium and monitored per protocol.", + facts: [ + ["Domain", "FBC/ANC"], + ["Target", "Record baseline result"], + ["Timing", "Before prescribing"], + ], + }, + { + id: "myocarditis", + priority: "Med", + title: "Myocarditis", + subtitle: "Monitor for signs/symptoms", + source: "Source 3", + accent: "text-orange-600 border-orange-200 bg-orange-50", + icon: Activity, + detail: "Screen for fever, chest pain, dyspnoea, tachycardia, and unexplained deterioration.", + facts: [ + ["Domain", "Cardiac symptoms"], + ["Target", "Escalate concerning signs"], + ["Timing", "During initiation"], + ], + }, + { + id: "metabolic", + priority: "Med", + title: "Metabolic", + subtitle: "Review weight, lipids, glucose", + source: "Source 4", + accent: "text-orange-600 border-orange-200 bg-orange-50", + icon: ClipboardCheck, + detail: "Record metabolic baseline and follow the scheduled monitoring pathway.", + facts: [ + ["Domain", "Weight and labs"], + ["Target", "Track trend over time"], + ["Timing", "Baseline and review"], + ], + }, + { + id: "renal", + priority: "Low", + title: "Renal function", + subtitle: "Check eGFR and U&Es", + source: "Source 6", + accent: "text-teal-700 border-teal-200 bg-teal-50", + icon: Stethoscope, + detail: "Check renal function before prescribing and repeat if toxicity or dehydration risk changes.", + facts: [ + ["Domain", "Renal"], + ["Target", "Confirm stable eGFR"], + ["Timing", "Baseline and periodic"], + ], + }, + { + id: "toxicity", + priority: "Low", + title: "Toxicity signs", + subtitle: "GI upset, tremor, ataxia", + source: "Source 8", + accent: "text-teal-700 border-teal-200 bg-teal-50", + icon: ShieldCheck, + detail: "Ask about toxicity symptoms when levels, renal function, or interacting medicines change.", + facts: [ + ["Domain", "Safety-net"], + ["Target", "Detect early toxicity"], + ["Timing", "At each review"], + ], + }, +]; + +const tabs: Array<{ id: TabId; label: string; icon: typeof ShieldIcon }> = [ + { id: "safety", label: "Safety", icon: ShieldIcon }, + { id: "monitor", label: "Monitor", icon: Activity }, + { id: "table", label: "Table", icon: Grid2X2 }, + { id: "checklist", label: "Checklist", icon: CheckSquare }, +]; + +const tableRows = [ + ["Baseline", "Record baseline result"], + ["Initiation", "Monitor per protocol"], + ["Ongoing", "Continue scheduled monitoring"], +] as const; + +const checklistItems = [ + ["Baseline level", "Record baseline lithium concentration"], + ["Renal function", "Check creatinine and eGFR"], + ["Thyroid", "Check TSH at baseline and periodically"], + ["Toxicity signs", "Screen for GI upset, tremor, ataxia"], +] as const; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--clinical-chat-teal)]"; + +function IconButton({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} + +function AppChrome() { + return ( +
+
+ +
+ + + + + + Mode + + Answer + + +
+
+ Clinical KB + +
+
+ +
+ + +
+
+
+

lithium

+

9:14 AM

+
+
+ + + +
+

+ Lithium Carbonate 250 mg Tablet - Lithicarb.{" "} + Lithium Carbonate 450 mg Modified Release Tablet - Quilonum SR. Lithium monitoring + baseline tests should be reviewed before prescribing. +

+ +
+
+ +
+ + +
+
+
+ +
+
+
+ ); +} + +function TabButton({ tab, active, onClick }: { tab: (typeof tabs)[number]; active: boolean; onClick: () => void }) { + const Icon = tab.icon; + return ( + + ); +} + +function FindingRow({ + finding, + expanded, + onClick, +}: { + finding: (typeof findings)[number]; + expanded: boolean; + onClick: () => void; +}) { + const Icon = finding.icon; + return ( +
+ + {expanded ? ( +
+
+

{finding.detail}

+
+ {finding.facts.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ + + + + + + + + {tableRows.map(([phase, action]) => ( + + + + + ))} + +
+ Phase + Action
{phase}{action}
+
+
+
+ ) : null} +
+ ); +} + +function Checklist({ checked, onToggle }: { checked: Set; onToggle: (index: number) => void }) { + return ( +
+
+

Checklist

+

{checked.size} of 4 selected

+
+
+ {checklistItems.map(([title, detail], index) => { + const selected = checked.has(index); + return ( + + ); + })} +
+
+ ); +} + +function CommandPopup() { + const [tab, setTab] = useState("safety"); + const [expanded, setExpanded] = useState("fbc"); + const [checked, setChecked] = useState>(new Set([0])); + const activeFinding = useMemo(() => findings.find((finding) => finding.id === expanded) ?? findings[0], [expanded]); + + function toggleCheck(index: number) { + setChecked((current) => { + const next = new Set(current); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + } + + return ( +
+
+
+
+ + + +
+
+

+ Clinical notes +

+ + 5 + +
+

+ Source-backed - one item expanded +

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

+ High-yield safety actions +

+ +
+ +
+ {findings.map((finding) => ( + setExpanded(finding.id)} + /> + ))} +
+ + {(tab === "checklist" || tab === "safety") && } +
+ + +
+
+ +
+ + + +
+
+ ); +} + +export default function ClinicalCommandPopupMockupPage() { + return ( +
+ +
+ +
+ ); +} diff --git a/src/app/mockups/evidence-option/page.tsx b/src/app/mockups/evidence-option/page.tsx new file mode 100644 index 0000000000..1c1108b856 --- /dev/null +++ b/src/app/mockups/evidence-option/page.tsx @@ -0,0 +1,770 @@ +import Image from "next/image"; +import type { Metadata } from "next"; +import { + ArrowRight, + BadgeCheck, + BookOpen, + CheckCircle2, + ChevronRight, + Copy, + ExternalLink, + Eye, + FileImage, + FileSearch, + FileText, + Filter, + Layers3, + Library, + ListChecks, + PanelRightOpen, + Quote, + Search, + ShieldCheck, + Sparkles, + SplitSquareHorizontal, + Table2, + Target, + 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.", +}; + +type Tone = "green" | "amber" | "blue" | "graphite"; + +const evidenceItems = [ + { + title: "ANC monitoring frequency table", + kind: "Table", + source: "Clozapine physical health protocol", + meta: "p. 12 - 97% direct support", + summary: "Baseline and ongoing blood monitoring cadence with hold thresholds.", + icon: Table2, + tone: "green", + }, + { + title: "Constipation escalation passage", + kind: "Quote", + source: "Clozapine safety bulletin", + meta: "p. 4 - exact quote", + summary: "Same-day review wording for severe constipation and abdominal symptoms.", + icon: Quote, + tone: "blue", + }, + { + title: "Observation pathway crop", + kind: "Image", + source: "Acute behavioural disturbance pathway", + meta: "p. 8 - visual extraction", + summary: "Diagram labels, observation frequency, and escalation checkpoints.", + 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", + }, +] 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"], +] 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; + +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 Badge({ children, variant = "graphite" }: { children: ReactNode; variant?: Tone }) { + return ( + + {children} + + ); +} + +function Glyph({ icon: Icon, variant = "graphite" }: { icon: LucideIcon; variant?: Tone }) { + return ( + + + + ); +} + +function GhostButton({ + children, + icon: Icon, + primary = false, +}: { + children: ReactNode; + icon?: LucideIcon; + primary?: boolean; +}) { + return ( + + ); +} + +function Section({ title, body, children }: { title: string; body: string; children: ReactNode }) { + return ( +
+
+

Premium evidence direction

+

{title}

+

{body}

+
+
{children}
+
+ ); +} + +function Frame({ label, title, children }: { label: string; title: string; children: ReactNode }) { + return ( +
+
+
+

{label}

+

{title}

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

{item.title}

+

{item.summary}

+ {!compact ?

{item.source}

: null} +
+ +
+
+ ); +} + +function EvidenceTable() { + return ( +
+
+ Stage + Evidence + Action +
+ {tableRows.map(([stage, evidence, action]) => ( +
+ {stage} + {evidence} + {action} +
+ ))} +
+ ); +} + +function TopNav() { + return ( +
+
+ + + + Clinical KB Evidence +
+
+ {["Home", "Search", "Tables", "Images", "Quotes"].map((item, index) => ( + + {item} + + ))} +
+
+ ); +} + +function HomeOne() { + 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}

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

Tables as first-class evidence

+

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

+
+ +
+
+
+ +

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) => ( + + ))} +
+
+
+
+ +
+ + ); +} + +function SearchOne() { + return ( + +
+ +
+ {sourceKinds.map(([label, count, Icon], index) => ( + + ))} +
+
+
+ {evidenceItems.map((item) => ( + + ))} +
+ +
+
+ + ); +} + +function SearchTwo() { + return ( + +
+ +
+ +
+ {evidenceItems.map((item) => ( + + ))} +
+
+
+ + ); +} + +function SearchThree() { + return ( + +
+
+

Evidence query

+
+

What supports weekly ANC monitoring during clozapine initiation?

+ + Build evidence pack + +
+
+
+ {[ + { 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}

+
+ ))} +
+
+ + ); +} + +function DetailHeader({ + title, + kind, + icon, + variant, +}: { + title: string; + kind: string; + icon: LucideIcon; + variant: Tone; +}) { + return ( +
+
+ +
+
+ {kind} + + + Source-backed + + p. 12 +
+

{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 +
+
+ +
+
+ + ); +} + +function DetailImage() { + return ( + +
+ +
+
+
+

Page crop and labels

+ Visual extraction +
+ Extracted risk flow image evidence +
+ +
+
+ + ); +} + +function DetailQuote() { + 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"} +
+
+ ))} +
+ +
+
+ + ); +} + +export default function EvidenceOptionMockupsPage() { + return ( +
+
+
+
+
+
+ + + +

+ 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. +

+
+ + 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}

+
+ ))} +
+
+
+
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+
+ ); +} diff --git a/src/app/mockups/favourites-hub/page.tsx b/src/app/mockups/favourites-hub/page.tsx index 86ab0b1935..e0f13d6181 100644 --- a/src/app/mockups/favourites-hub/page.tsx +++ b/src/app/mockups/favourites-hub/page.tsx @@ -1,449 +1,5 @@ -import { - BadgeCheck, - BookOpen, - BookOpenCheck, - ChevronDown, - Clock, - Database, - ExternalLink, - FileText, - Filter, - Folder, - HeartPulse, - ListChecks, - Pill, - Pin, - Plus, - Search, - ShieldAlert, - Sparkles, - Star, - Stethoscope, - Tag, - Target, -} from "lucide-react"; -import type { ReactNode } from "react"; +import { redirect } from "next/navigation"; -const favouriteStats = [ - ["Saved items", "42", "Across sources, medications, documents, searches, and clinical tools"], - ["Review due", "6", "Favourites with source governance or medication safety checks pending"], - ["Pinned sets", "5", "Reusable clinical bundles for ward, clinic, and prescribing workflows"], - ["Used today", "18", "Recently opened, scoped, copied, or added to an answer"], -] as const; - -const categoryTabs = [ - ["All", "42"], - ["Sources", "14"], - ["Medications", "9"], - ["Documents", "12"], - ["Searches", "4"], - ["Tools", "3"], -] as const; - -const favouriteCards = [ - { - title: "Clozapine physical health protocol", - type: "Document", - detail: "Shared-care thresholds, monitoring cadence, escalation actions, and metabolic risk tables.", - meta: "42 pages - review due - 4 tables", - accent: "review", - icon: FileText, - tags: ["Protocol", "Clozapine", "Monitoring"], - }, - { - title: "Lithium monitoring quick set", - type: "Favourite set", - detail: "Document scope, renal/thyroid source passages, toxicity warning table, and patient leaflet.", - meta: "4 saved items - used 2h ago", - accent: "current", - icon: Folder, - tags: ["Pinned", "Shared care", "Renal"], - }, - { - title: "Acamprosate", - type: "Medication", - detail: "Quick prescribing, renal screen, dose limits, PBS access, and source-backed safety notes.", - meta: "S4 - reviewed - prescribing page", - accent: "medication", - icon: Pill, - tags: ["AUD", "Dose", "Renal screen"], - }, - { - title: "ECT referral and consent pathway", - type: "Source", - detail: "Workflow pages, consent checkpoints, capacity considerations, and related forms grouped together.", - meta: "Current - policy - 12 pages", - accent: "current", - icon: BookOpenCheck, - tags: ["Workflow", "Consent", "Forms"], - }, -] as const; - -const savedCollections = [ - ["Ward round", "Lithium, clozapine, rapid tranquillisation, ECT forms", "12 items"], - ["Prescribing safety", "Dose pages, renal alerts, pregnancy cautions, PBS source checks", "9 items"], - ["Document QA", "Review due sources, low extraction quality, missing tables", "6 items"], -] as const; - -const timelineRows = [ - ["Pinned", "Acamprosate prescribing page", "Medication"], - ["Added", "Clozapine protocol table 4", "Source"], - ["Scoped", "Lithium monitoring quick set", "Favourite set"], - ["Reviewed", "ECT referral and consent pathway", "Document"], -] as const; - -const addOptions = [ - ["Current answer", "Save the answer, citations, source previews, and clinical note context.", Sparkles], - ["Medication page", "Pin dose, safety, monitoring, access, and source review state together.", Pill], - ["Document or source", "Add a PDF, guideline, table passage, image, or signed source link.", FileText], - ["Search scope", "Save query, filters, document scope, and ranking explanation as a reusable set.", Search], -] as const; - -function toneClass(accent: (typeof favouriteCards)[number]["accent"]) { - if (accent === "review") { - return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; - } - if (accent === "medication") { - return "border-[color:var(--primary)]/30 bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)]"; - } - return "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]"; -} - -function StatCard({ label, value, body }: { label: string; value: string; body: string }) { - return ( -
-

{label}

-

{value}

-

{body}

-
- ); -} - -function ActionButton({ children, primary = false }: { children: ReactNode; primary?: boolean }) { - const className = primary - ? "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[color:var(--clinical-chat-teal)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-tight)] hover:bg-[color:var(--primary-strong)]" - : "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-semibold text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]"; - - return ( - - ); -} - -function FavouriteCard({ item }: { item: (typeof favouriteCards)[number] }) { - const Icon = item.icon; - return ( -
-
- - - -
-
- - {item.type} - - - {item.accent === "review" ? "Review due" : item.accent === "medication" ? "Prescribing" : "Current"} - -
-

{item.title}

-

{item.detail}

-

{item.meta}

-
- {item.tags.map((tag) => ( - - - {tag} - - ))} -
-
-
- - -
-
-
- ); -} - -function AddOption({ option }: { option: (typeof addOptions)[number] }) { - const [title, body, Icon] = option; - return ( - - ); -} - -export default function FavouritesHubMockupPage() { - return ( -
-
-
-
-
-
- - - -

- Clinical KB favourites mockup -

-
-

- A source-first favourites hub for saved clinical work -

-

- Save medications, documents, source passages, searches, clinical tools, and reusable bundles into one - fast workspace without losing review state, provenance, or prescribing safety context. -

-
- - - Add favourite - - - - Manage pinned sets - - - - Review due - -
-
- -
-
- -
- {favouriteStats.map(([label, value, body]) => ( - - ))} -
- -
-
-
-
-
-

- Browse favourites -

-

- Saved work, grouped by clinical use -

-
-
- - - Filters - - - - - Recent first - -
-
- -
- -
- {categoryTabs.map(([label, count], index) => ( - - ))} -
-
-
- -
- {favouriteCards.map((item) => ( - - ))} -
-
- - -
- -
-
-
-
- -

Hub structure

-
- - Source-backed by default - -
-
- {[ - ["Save anywhere", "Add from answer actions, source cards, medication pages, document rows, and search scope.", Target], - ["Reuse safely", "Open with review status, original citations, filters, and document scope already attached.", BadgeCheck], - ["Stay organised", "Collections, pinned sets, recency, tags, and review queues keep high-volume libraries scannable.", Database], - ].map(([title, body, Icon]) => ( -
- -

{title as string}

-

{body as string}

-
- ))} -
-
- - -
- -
-
-
-
- -

Clinical guardrails

-
-

- Favourites should make repeat work faster without stripping away the source trail. -

-
-
- {[ - ["Medication favourites", "Always show dose limiter, review state, and contraindication summary.", Stethoscope], - ["Document favourites", "Keep page, chunk, table/image counts, review status, and best passage.", BookOpen], - ["Saved searches", "Persist query mode, filters, scope, result ordering, and coverage notes.", Search], - ].map(([title, body, Icon]) => ( -
- -

{title as string}

-

{body as string}

-
- ))} -
-
-
-
-
- ); +export default function FavouritesHubMockupRedirect() { + redirect("/?mode=favourites"); } diff --git a/src/app/mockups/layout.tsx b/src/app/mockups/layout.tsx new file mode 100644 index 0000000000..33a8f03872 --- /dev/null +++ b/src/app/mockups/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +import { GlobalMockupSearchShell } from "@/components/clinical-dashboard/global-mockup-search-shell"; + +export default function MockupsLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/mockups/medication-prescribing/page.tsx b/src/app/mockups/medication-prescribing/page.tsx index 503130c6e9..6605cf72ba 100644 --- a/src/app/mockups/medication-prescribing/page.tsx +++ b/src/app/mockups/medication-prescribing/page.tsx @@ -1,495 +1,5 @@ -import { - Activity, - AlertTriangle, - BadgeCheck, - BookOpenCheck, - ClipboardCheck, - FileText, - Gauge, - HeartPulse, - ListChecks, - MonitorCheck, - Pill, - Search, - ShieldAlert, - Sparkles, - Stethoscope, -} from "lucide-react"; +import { redirect } from "next/navigation"; -const quickRows = [ - ["Indication", "Alcohol abstinence maintenance"], - ["Form", "333 mg enteric-coated tablet"], - ["Typical dose", "666 mg three times daily with meals"], - ["Maximum", "1998 mg/day"], - ["Schedule", "S4"], - ["Access", "Streamlined PBS authority"], -] as const; - -const safetyRows = [ - ["Renal", "Do not use if renal insufficiency is present; check serum creatinine before prescribing."], - ["Hepatic", "Avoid severe hepatic failure. Mild to moderate impairment still needs review."], - ["Pregnancy", "Contraindicated in pregnancy and breastfeeding in the reviewed PI."], - ["Age", "Avoid under 18 and over 65 where safety and efficacy are not established."], -] as const; - -const monitoringRows = [ - ["Baseline", "Renal function, hepatic status, pregnancy or lactation status, alcohol withdrawal completion."], - ["Early review", "Adherence, gastrointestinal intolerance, mood symptoms, ongoing alcohol use."], - ["Ongoing", "Renal function if clinical status changes; continue psychosocial support."], -] as const; - -const sourceRows = [ - ["Australian PI", "Reviewed source - Campral product information"], - ["PBS", "Streamlined authority item present"], - ["Clinical KB", "Related guideline passages available"], -] as const; - -const navItems = ["Summary", "Dose", "Safety", "Monitoring", "Sources"] as const; - -function MiniStat({ label, value, tone = "neutral" }: { label: string; value: string; tone?: "neutral" | "warn" | "ok" }) { - const toneClass = - tone === "warn" - ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" - : tone === "ok" - ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" - : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)]"; - - return ( -
-

{label}

-

{value}

-
- ); -} - -function SearchResultMock({ variant }: { variant: "compact" | "split" | "clinical" }) { - const resultClass = - variant === "split" - ? "grid gap-3 lg:grid-cols-[1fr_22rem]" - : variant === "clinical" - ? "grid gap-3 xl:grid-cols-[minmax(0,1fr)_18rem]" - : "grid gap-3"; - - return ( -
-
-
- - - -
-

Search: acamprosate dose renal

-

- Medication result appears beside source-backed documents, not instead of them. -

-
-
-
- Answer - Sources - - Medication - -
-
- -
- -
-
-
-

Acamprosate

- - S4 - - - Reviewed - -
-

- Anti-craving option for maintaining alcohol abstinence. Renal screening is the prescribing limiter. -

-
- - Open medication page - -
-
- - - -
-
- - {variant !== "compact" ? ( -
-

Matched evidence

-
- {sourceRows.slice(0, variant === "split" ? 2 : 3).map(([label, value]) => ( -
- -

- {label}: - {value} -

-
- ))} -
-
- ) : null} -
-
- ); -} - -function RowList({ rows }: { rows: readonly (readonly [string, string])[] }) { - return ( -
- {rows.map(([label, value]) => ( -
-

{label}

-

{value}

-
- ))} -
- ); -} - -function PageHeader({ direction, title, body }: { direction: string; title: string; body: string }) { - return ( -
-
-

{direction}

-

{title}

-

{body}

-
-
- {navItems.map((item, index) => ( - - {item} - - ))} -
-
- ); -} - -function DirectionOne() { - return ( -
- -
- -
-
-
-
-
-

Medication page

-

Acamprosate

-

- Addiction Medicine - GABA / Glutamate modulator - S4 -

-
- - Source reviewed - -
-
- - - - -
-
- -
-
- -

Quick prescribing

-
- -
- -
-
- -

Safety checks before signing

-
- -
-
- - -
-
-
- ); -} - -function DirectionTwo() { - return ( -
- -
- -
-
-
-

Acamprosate prescribing workspace

-

- Everything needed to start, avoid, adjust, and monitor without leaving the page. -

-
-
- - AUD - - - PBS - - - TGA - -
-
- -
- {[ - ["Start", "666 mg TDS after withdrawal period", Stethoscope], - ["Adjust", "Weight under 60 kg uses lower daily split", Gauge], - ["Avoid", "Renal insufficiency, pregnancy, breastfeeding", ShieldAlert], - ["Monitor", "Renal function, adherence, mood, GI effects", MonitorCheck], - ].map(([label, value, Icon]) => ( -
- -

{label as string}

-

{value as string}

-
- ))} -
- -
-
-
-
- -

Prescribing data

-
- -
-
-
- -

Monitoring

-
- -
-
- -
-
-
-
- ); -} - -function DirectionThree() { - return ( -
- -
- -
- - -
-
-
- -

Prescribing summary

-
-

- Consider for alcohol abstinence maintenance after withdrawal when renal function, pregnancy or lactation - status, hepatic severity, and age do not trigger an avoid rule. -

-
- -
-
-
- -

Dose and access

-
- -
-
-
- -

Safety screen

-
- -
-
-
- - -
-
-
- ); -} - -export default function MedicationPrescribingMockupsPage() { - return ( -
-
-
-
-
-

Clinical KB medication search mockups

-

- Three lightweight directions for prescribing-focused medication pages -

-

- These mockups keep the quiet Clinical KB teal and source-first styling while borrowing the Medication app - information model: quick prescribing, decision summary, safety screen, monitoring, patient-context checks, - and source review status. -

-
-
-

Design recommendation

-

- Direction 1 is the strongest default: lowest cognitive load, closest to the current answer workflow, and - easiest to ship incrementally. -

-
-
-
- - - - - -
-
- -

Review notes

-
-
-

- Current Clinical KB works best when primary actions stay teal, clinical notes use sand, and evidence remains - close to the answer. The mockups preserve that rhythm. -

-

- Medication app content maps cleanly to search tabs plus pages: stats, quick rows, sections, patient metadata, - source status, and related guideline evidence. -

-

- The implementation should start read-only and source-backed. Patient-context matching can be layered in after - the basic medication tab and page route are reliable. -

-
-
-
-
- ); +export default function MedicationPrescribingMockupRedirectPage() { + redirect("/medications/acamprosate"); } diff --git a/src/app/mockups/mode-dropdown/page.tsx b/src/app/mockups/mode-dropdown/page.tsx index a73d747d18..a40a8965c5 100644 --- a/src/app/mockups/mode-dropdown/page.tsx +++ b/src/app/mockups/mode-dropdown/page.tsx @@ -10,7 +10,6 @@ import { Search, Sparkles, Stethoscope, - UserRound, } from "lucide-react"; const modes = [ @@ -44,12 +43,6 @@ const modes = [ icon: Heart, active: false, }, - { - label: "Profile", - description: "Home, preferences, review queue", - icon: UserRound, - active: false, - }, ] as const; function HeaderMockup({ expanded = false, compact = false }: { expanded?: boolean; compact?: boolean }) { diff --git a/src/app/mockups/recent-searches-bottom/page.tsx b/src/app/mockups/recent-searches-bottom/page.tsx new file mode 100644 index 0000000000..2783ef3ea1 --- /dev/null +++ b/src/app/mockups/recent-searches-bottom/page.tsx @@ -0,0 +1,272 @@ +import { + BookOpenCheck, + Clock3, + FileText, + Filter, + Menu, + Mic, + Moon, + Plus, + Search, + Send, + Sparkles, + X, +} from "lucide-react"; + +const recentSearches = [ + "lithium renal monitoring", + "clozapine ANC thresholds", + "acamprosate renal dose", + "ECT consent capacity", + "rapid tranquillisation ECG", +] as const; + +const sourceCards = [ + ["Lithium monitoring guideline", "Renal, thyroid, calcium, toxicity warning signs", "Updated 2d ago"], + ["Clozapine shared-care protocol", "ANC schedule, myocarditis screen, metabolic monitoring", "Review due"], + ["Acamprosate prescribing note", "Dose thresholds, contraindications, PBS access", "Current"], +] as const; + +function HeaderMockup() { + return ( +
+
+ + + + +
+ + + + AK + + +
+
+
+ ); +} + +function SidebarMockup() { + return ( + + ); +} + +function RecentSearchRail() { + return ( +
+ + + Recent + +
+ {recentSearches.map((search) => ( + + ))} +
+ +
+ ); +} + +function BottomComposerMockup() { + return ( +
+ +
+ + + + +
+
+ ); +} + +function SourcePreviewGrid() { + return ( +
+ {sourceCards.map(([title, body, meta]) => ( +
+
+ + + + + {meta} + +
+

{title}

+

{body}

+
+ ))} +
+ ); +} + +export default function RecentSearchesBottomMockupPage() { + return ( +
+
+ +
+ +
+
+
+
+
+ + + + + Ready + + + All sources + +
+

+ Ask from your indexed clinical sources +

+

+ Recent searches sit near the bottom composer as low-emphasis shortcuts, leaving the answer canvas + quiet until the clinician starts a new query. +

+
+ + + +
+
+ +

+ Scope and mode stay primary +

+ + Recent is secondary + +
+
+
+
+
+ +
+
+
+ ); +} diff --git a/src/app/mockups/settings-search-clinical/page.tsx b/src/app/mockups/settings-search-clinical/page.tsx new file mode 100644 index 0000000000..9d3eb36391 --- /dev/null +++ b/src/app/mockups/settings-search-clinical/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { SettingsSearchMockupPage } from "@/components/settings-search-mockups"; + +export const metadata: Metadata = { + title: "Search Settings Clinical Mockup - Clinical KB", + description: "Search-first ChatGPT-style clinical defaults settings mockup for Clinical KB.", +}; + +export default function SettingsSearchClinicalMockupRoute() { + return ; +} diff --git a/src/app/mockups/settings-search-general/page.tsx b/src/app/mockups/settings-search-general/page.tsx new file mode 100644 index 0000000000..df9b3793db --- /dev/null +++ b/src/app/mockups/settings-search-general/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { SettingsSearchMockupPage } from "@/components/settings-search-mockups"; + +export const metadata: Metadata = { + title: "Search Settings General Mockup - Clinical KB", + description: "Search-first ChatGPT-style general settings mockup for Clinical KB.", +}; + +export default function SettingsSearchGeneralMockupRoute() { + return ; +} diff --git a/src/app/mockups/settings-search-privacy/page.tsx b/src/app/mockups/settings-search-privacy/page.tsx new file mode 100644 index 0000000000..5e7c50c9bc --- /dev/null +++ b/src/app/mockups/settings-search-privacy/page.tsx @@ -0,0 +1,12 @@ +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.", +}; + +export default function SettingsSearchPrivacyMockupRoute() { + return ; +} diff --git a/src/app/mockups/user-home-profile/page.tsx b/src/app/mockups/user-home-profile/page.tsx deleted file mode 100644 index f441e1e9b5..0000000000 --- a/src/app/mockups/user-home-profile/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Metadata } from "next"; - -import { UserHomeProfilePage } from "@/components/user-home-profile"; - -export const metadata: Metadata = { - title: "User Home Profile Mockup - Clinical KB", - description: "ChatGPT-style logged-in profile home mockup for Clinical KB users.", -}; - -export default function UserHomeProfileMockupRoute() { - return ; -} diff --git a/src/app/page.tsx b/src/app/page.tsx index bdb327a09f..9f76ba479e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,31 @@ import { ClinicalDashboard } from "@/components/clinical-dashboard"; +import { isAppModeId, isAppModeVisible, type AppModeId } from "@/lib/app-modes"; -export default function Home() { - return ; +type HomeProps = { + searchParams?: Promise<{ + mode?: string | string[]; + q?: string | string[]; + focus?: string | string[]; + }>; +}; + +function firstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function Home({ searchParams }: HomeProps) { + const params = searchParams ? await searchParams : {}; + const requestedMode = firstSearchParam(params.mode); + const requestedQuery = firstSearchParam(params.q)?.trim() ?? ""; + const requestedFocus = firstSearchParam(params.focus); + const initialSearchMode: AppModeId = + isAppModeId(requestedMode) && isAppModeVisible(requestedMode) ? requestedMode : "answer"; + + return ( + + ); } diff --git a/src/app/tools/page.tsx b/src/app/tools/page.tsx deleted file mode 100644 index 7e6d835f6a..0000000000 --- a/src/app/tools/page.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import Link from "next/link"; -import { - ArrowDown, - ArrowRight, - BookOpen, - Brain, - CheckCircle2, - CircleDashed, - ClipboardCheck, - ClipboardList, - ExternalLink, - FileImage, - FileText, - HeartHandshake, - LayoutList, - ListChecks, - Network, - Pill, - Quote, - Search, - ShieldAlert, - Sparkles, - Target, - UploadCloud, - type LucideIcon, -} from "lucide-react"; -import { cn } from "@/components/ui-primitives"; -import { defaultFavoriteToolIds, type ToolCategory, type ToolIconName, type ToolItem, toolCatalog } from "@/lib/tools"; - -type ToneName = "primary" | "info" | "success" | "warning" | "danger"; - -type ToneTheme = { - aura: string; - border: string; - button: string; - glow: string; - icon: string; - rail: string; - surface: string; - text: string; -}; - -type ToolPresentation = { - cadence: string; - role: string; - shortLabel: string; - tone: ToneName; -}; - -const iconRegistry = { - Brain, - ClipboardList, - Search, - FileImage, - FileText, - HeartHandshake, - Network, - Pill, - UploadCloud, - BookOpen, - ClipboardCheck, - ListChecks, - Sparkles, - ShieldAlert, - Quote, - Target, - ExternalLink, - Clipboard: ClipboardCheck, -} satisfies Record; - -const toneTheme: Record = { - primary: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--primary)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--primary)_28%,transparent)]", - button: "bg-[color:var(--primary)] text-[color:var(--primary-contrast)] hover:bg-[color:var(--primary-strong)]", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--primary)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--primary)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--primary)_14%,transparent)] text-[color:var(--primary-100)]", - rail: "bg-[color:var(--primary)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--app-shell-muted)_82%,transparent),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--primary-100)]", - }, - info: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--info)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--info)_30%,transparent)]", - button: "bg-[color:var(--info-soft)] text-[color:var(--app-shell)] hover:bg-white", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--info)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--info)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--info)_14%,transparent)] text-[color:var(--info-bg)]", - rail: "bg-[color:var(--info)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--info)_16%,var(--app-shell-muted)),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--info-bg)]", - }, - success: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--success)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--success)_30%,transparent)]", - button: "bg-[color:var(--success-soft)] text-[color:var(--app-shell)] hover:bg-white", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--success)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--success)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--success)_14%,transparent)] text-[color:var(--success-bg)]", - rail: "bg-[color:var(--success)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--success)_16%,var(--app-shell-muted)),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--success-bg)]", - }, - warning: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--warning)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--warning)_30%,transparent)]", - button: "bg-[color:var(--warning-soft)] text-[color:var(--app-shell)] hover:bg-white", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--warning)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--warning)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--warning)_14%,transparent)] text-[color:var(--warning-bg)]", - rail: "bg-[color:var(--warning)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--warning)_16%,var(--app-shell-muted)),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--warning-bg)]", - }, - danger: { - aura: "bg-[radial-gradient(circle_at_18%_0%,color-mix(in_srgb,var(--danger)_20%,transparent),transparent_18rem)]", - border: "border-[color:color-mix(in_srgb,var(--danger)_30%,transparent)]", - button: "bg-[color:var(--danger-soft)] text-[color:var(--app-shell)] hover:bg-white", - glow: "shadow-[0_18px_52px_color-mix(in_srgb,var(--danger)_14%,transparent)]", - icon: "border-[color:color-mix(in_srgb,var(--danger)_34%,transparent)] bg-[color:color-mix(in_srgb,var(--danger)_14%,transparent)] text-[color:var(--danger-bg)]", - rail: "bg-[color:var(--danger)]", - surface: - "bg-[linear-gradient(145deg,color-mix(in_srgb,var(--danger)_16%,var(--app-shell-muted)),color-mix(in_srgb,var(--app-shell)_96%,black))]", - text: "text-[color:var(--danger-bg)]", - }, -}; - -const categoryTone: Record = { - Admin: "danger", - Clinical: "primary", - Docs: "info", - Operations: "warning", - Research: "success", -}; - -const toolPresentation: Record = { - differentials: { - cadence: "Rule-outs", - role: "Check likely rule-outs, red flags, and competing DSM-5 explanations.", - shortLabel: "Diffs", - tone: "success", - }, - "dsm-5-diagnoses": { - cadence: "DSM-5 criteria", - role: "Open criteria, symptom clusters, and diagnostic anchors.", - shortLabel: "DSM", - tone: "success", - }, - forms: { - cadence: "Capture", - role: "Start structured intake, review, and patient-facing form workflows.", - shortLabel: "Forms", - tone: "warning", - }, - formulation: { - cadence: "Case theory", - role: "Build formulation from problems, risks, maintaining factors, and treatment direction.", - shortLabel: "Form", - tone: "primary", - }, - medications: { - cadence: "Prescribing", - role: "Check prescribing context, monitoring, safety issues, and medication review.", - shortLabel: "Meds", - tone: "primary", - }, - "psychiatry-notes": { - cadence: "Output", - role: "Open summaries, documentation flows, and review-ready note outputs.", - shortLabel: "Notes", - tone: "danger", - }, - services: { - cadence: "Pathways", - role: "Find referral pathways, access points, and service-matching options.", - shortLabel: "Svc", - tone: "warning", - }, - specifiers: { - cadence: "Qualifiers", - role: "Review severity, course, and specifier language for a diagnosis.", - shortLabel: "Spec", - tone: "info", - }, - therapy: { - cadence: "Treatment", - role: "Open treatment planning, session structure, and intervention options.", - shortLabel: "Tx", - tone: "primary", - }, -}; - -const favoriteTools = defaultFavoriteToolIds - .map((id) => toolCatalog.find((tool) => tool.id === id)) - .filter((tool): tool is ToolItem => Boolean(tool)); - -function isInactive(tool: ToolItem) { - return tool.status === "offline" || tool.status === "coming-soon"; -} - -function getPresentation(tool: ToolItem) { - return ( - toolPresentation[tool.id] ?? { - cadence: tool.category, - role: tool.description, - shortLabel: tool.title, - tone: categoryTone[tool.category], - } - ); -} - -function getLaunchContext(tool: ToolItem) { - try { - const url = new URL(tool.href); - return `${url.hostname}${url.port ? `:${url.port}` : ""}`; - } catch { - return tool.target === "external" ? "External app" : "Internal route"; - } -} - -function getStatusLabel(tool: ToolItem) { - if (tool.status === "coming-soon") return "Soon"; - if (tool.status === "offline") return "Paused"; - if (tool.status === "beta") return "Preview"; - return "Live"; -} - -function LaunchCard({ tool }: { tool: ToolItem }) { - const Icon = iconRegistry[tool.icon]; - const disabled = isInactive(tool); - const presentation = getPresentation(tool); - const theme = toneTheme[presentation.tone]; - const target = tool.target === "external" && tool.openInNewTab ? "_blank" : undefined; - const rel = tool.target === "external" ? "noopener noreferrer" : undefined; - - return ( -
-
-
-
- -
-
- - - - - {disabled ? ( - - ) : ( - - )} - {getStatusLabel(tool)} - -
- -
-

{presentation.cadence}

-

- {tool.title} -

-

{presentation.role}

-
- -
-
- {getLaunchContext(tool)} - {disabled ? ( - - Unavailable - - ) : ( - - Launch - - - )} -
-
-
-
- ); -} - -function AppDock({ tools }: { tools: ToolItem[] }) { - return ( -
- {tools.map((tool) => { - const Icon = iconRegistry[tool.icon]; - const presentation = getPresentation(tool); - const theme = toneTheme[presentation.tone]; - - return ( - - - - - {presentation.shortLabel} - {presentation.cadence} - - - ); - })} -
- ); -} - -function HeroConsole() { - return ( - - ); -} - -export default function ToolsLauncherPage() { - return ( -
-
-
-
-
- -
-
- - -
-
-

- Open the right clinical tool. -

-

- Jump to formulation, DSM-5 criteria, medications, differentials, notes, forms, therapy, specifiers, or - service pathways. Each launch opens the local app shown on the card. -

-
- - -
-
- -
-
-
-
-

All clinical tools

-

- Use the host label to confirm the local app, then open the tool in a new tab. -

-
- - - Opens in a new tab - -
- -
- {toolCatalog.map((tool) => ( - - ))} -
-
-
-
-
-
- ); -} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index c648d80cbc..578f2c612d 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3,19 +3,28 @@ /* eslint-disable @next/next/no-img-element */ import Link from "next/link"; +import { useRouter } from "next/navigation"; import { + Activity, AlertCircle, + ArrowUpDown, BookOpen, Brain, CheckCircle2, ChevronDown, ClipboardCheck, + ClipboardList, Copy, ExternalLink, FileImage, FileText, Filter, + Folder, + FolderInput, + Heart, + HeartHandshake, Layers, + LayoutList, ListChecks, Loader2, LogIn, @@ -23,6 +32,7 @@ import { Mail, MessageSquare, MoreHorizontal, + Network, PanelLeftClose, PanelLeftOpen, Pill, @@ -35,10 +45,12 @@ import { ShieldCheck, SlidersHorizontal, Sparkles, + Table2, Tag, Target, UploadCloud, WifiOff, + Wrench, X, } from "lucide-react"; import { @@ -52,7 +64,11 @@ import { useSyncExternalStore, } from "react"; import { AccessibleTable } from "@/components/AccessibleTable"; -import { DocumentOrganizationBadges, documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; +import { + DocumentOrganizationBadges, + documentDisplayTitle, + documentOrganizationProfile, +} from "@/components/DocumentOrganizationBadges"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { documentCitationHref, formatCompactCitationLabel, formatCitationLabel } from "@/lib/citations"; @@ -98,6 +114,7 @@ import { tableCardHeader, tableMicroActionRow, textMuted, + toolbarButton, toneDanger, toneInfo, toneNeutral, @@ -125,6 +142,7 @@ import { sourceDisplayTitle, } from "@/components/clinical-dashboard/display-text"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; import { DocumentSearchResultsPanel, MatchExplanationChips, @@ -151,10 +169,20 @@ import { type AnswerPayload, type SearchError, } from "@/components/clinical-dashboard/search-utils"; +import { + appModeQueryMode, + appModeHomeHref, + appModeResultKind, + appModeSearchConfig, + isAppModeId, + isAppModeVisible, + type AppModeId, +} from "@/lib/app-modes"; import { logSourceOpen, SourceActionRow, sourceResultHref } from "@/components/clinical-dashboard/source-actions"; 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, @@ -234,7 +262,6 @@ type DocumentPagination = { nextOffset: number; hasMore: boolean; }; -type SearchMode = "answer" | "documents"; type RefreshOptions = { includeSetup?: boolean; includeDashboardData?: boolean; @@ -282,12 +309,7 @@ type AnswerFeedbackType = | "numeric_error" | "outdated_guidance"; type IngestionQualityReviewType = - | "failed_ocr" - | "low_extraction_confidence" - | "missing_tables" - | "image_only_pages" - | "failed_job" - | "manual_review"; + "failed_ocr" | "low_extraction_confidence" | "missing_tables" | "image_only_pages" | "failed_job" | "manual_review"; type IngestionQualityReviewItem = { id: string; type: IngestionQualityReviewType; @@ -1071,6 +1093,16 @@ function isRedundantStructuredItem(item: string, primaryAnswer: string) { type ClinicalDetailSection = ReturnType[number]; +function displayItemsForClinicalDetailSection( + section: ClinicalDetailSection, + primaryAnswer: string, + showLead: boolean, +) { + if (showLead) return section.items; + const nonRedundantItems = section.items.filter((item) => !isRedundantStructuredItem(item, primaryAnswer)); + return nonRedundantItems.length > 0 || section.items.length === 0 ? nonRedundantItems : section.items; +} + const clinicalDetailPriority: Record = { action: 10, escalation: 20, @@ -1170,6 +1202,388 @@ function clinicalDetailSummaryItems(sections: ClinicalDetailSection[]) { return items.filter((item) => item.value > 0); } +type ClinicalNotesTabId = "safety" | "monitor"; + +type ClinicalNotesRow = { + id: string; + title: string; + detail: string; + sourceIndex: number; + tone: "safe" | "warn"; +}; + +const clinicalNotesTabMeta: Record< + ClinicalNotesTabId, + { label: string; icon: typeof ShieldCheck; sectionIds: string[] } +> = { + safety: { + label: "Safety", + icon: ShieldCheck, + sectionIds: ["escalation", "cautions", "source-gap", "thresholds"], + }, + monitor: { + label: "Monitor", + icon: Activity, + sectionIds: ["monitoring", "medication", "action"], + }, +}; + +function compactClinicalNoteText(value: string) { + return value + .replace(/\*\*/g, "") + .replace(/\s*\[\d+(?:,\s*\d+)*\]\s*/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function clinicalNoteTitleFromItem(item: string, section: ClinicalDetailSection, index: number) { + const text = compactClinicalNoteText(item); + const colonIndex = text.indexOf(":"); + if (colonIndex > 8 && colonIndex < 54) return text.slice(0, colonIndex).trim(); + 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); + 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 normalizedTitle = title.toLowerCase(); + const lowerText = text.toLowerCase(); + if (lowerText.startsWith(`${normalizedTitle}:`)) return text.slice(title.length + 1).trim(); + if (lowerText.startsWith(`${normalizedTitle} -`) || lowerText.startsWith(`${normalizedTitle} –`)) { + return text.slice(title.length + 2).trim(); + } + return text === title ? "Review linked source context before using this note." : text; +} + +function clinicalNoteTitleFromFragment(fragment: string) { + const text = compactClinicalNoteText(fragment) + .replace(/^(and|or)\s+/i, "") + .replace(/[.;:,]+$/g, ""); + if (!text) return "Clinical note"; + return text.replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +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 fragments = detail + .replace(/\band\s+/gi, "") + .split(/[,;]\s+/) + .map((fragment) => compactClinicalNoteText(fragment).replace(/[.;:,]+$/g, "")) + .filter((fragment) => fragment.length > 5); + + return fragments.length >= 3 ? fragments.slice(0, 5) : null; +} + +function clinicalNoteToneForText(text: string, fallback: ClinicalNotesRow["tone"]) { + if (/\b(toxicity|toxic|warning|caution|urgent|red flag|adverse|confusion|ataxia|tremor)\b/i.test(text)) { + return "warn"; + } + return fallback; +} + +function clinicalNoteHasDistinctDetail(row: ClinicalNotesRow) { + const title = compactClinicalNoteText(row.title).toLowerCase(); + const detail = compactClinicalNoteText(row.detail).toLowerCase(); + return Boolean(detail) && detail !== title; +} + +function clinicalNotesTableEvidenceCount(answer: RagAnswer) { + return (answer.visualEvidence ?? answer.smartPanel?.visualEvidence ?? []).filter( + (item) => item.accessibleTableMarkdown || item.tableRows?.length, + ).length; +} + +function clinicalNotesRowsForTab(sections: ClinicalDetailSection[], tab: ClinicalNotesTabId) { + const meta = clinicalNotesTabMeta[tab]; + const rows: ClinicalNotesRow[] = []; + let sourceIndex = 1; + + for (const section of sections) { + const sectionText = `${section.title} ${section.items.join(" ")}`.toLowerCase(); + const hasMonitoringText = + tab === "monitor" && /\b(monitor|screen|level|fbc|anc|metabolic|renal|thyroid|function)\b/i.test(sectionText); + if (!meta.sectionIds.includes(section.id) && !hasMonitoringText) { + continue; + } + const tone: ClinicalNotesRow["tone"] = section.id === "escalation" || section.id === "cautions" ? "warn" : "safe"; + + for (const item of section.items.slice(0, 4)) { + if (section.tables?.length && /\b(table|showing domains|table showing)\b/i.test(item)) continue; + const title = clinicalNoteTitleFromItem(item, section, rows.length); + const fragments = splitClinicalNoteFragments(item, section, title); + if (fragments) { + for (const fragment of fragments) { + const fragmentTitle = clinicalNoteTitleFromFragment(fragment); + rows.push({ + id: `${tab}:${section.id}:${rows.length}:${fragmentTitle}`, + title: fragmentTitle, + detail: fragment, + sourceIndex: sourceIndex++, + tone: clinicalNoteToneForText(fragment, tone), + }); + } + } else { + rows.push({ + id: `${tab}:${section.id}:${rows.length}:${title}`, + title, + detail: clinicalNoteDetailFromItem(item, title), + sourceIndex: sourceIndex++, + tone: clinicalNoteToneForText(item, tone), + }); + } + } + } + + return rows.slice(0, 6); +} + +function clinicalNotesAvailableTabs(sections: ClinicalDetailSection[]) { + return (Object.keys(clinicalNotesTabMeta) as ClinicalNotesTabId[]) + .map((id) => ({ id, ...clinicalNotesTabMeta[id], count: clinicalNotesRowsForTab(sections, id).length })) + .filter((tab) => tab.count > 0); +} + +function clinicalNotesDetailSectionsForAnswer(answer: RagAnswer, viewMode: AnswerViewMode) { + const sections = + viewMode === "high_yield" ? buildHighYieldClinicalOutputSections(answer) : buildClinicalOutputSections(answer); + const primaryAnswer = plainAnswerText(answer.answer); + return sortClinicalDetailSections( + sections + .filter((section) => section.id !== "verify-source" && section.id !== "bottom-line") + .map((section) => ({ + ...section, + items: displayItemsForClinicalDetailSection(section, primaryAnswer, false), + })) + .filter((section) => section.items.length > 0), + ); +} + +function clinicalNotesDisplayCountForAnswer(answer: RagAnswer, viewMode: AnswerViewMode, fallback: number) { + const tabs = clinicalNotesAvailableTabs(clinicalNotesDetailSectionsForAnswer(answer, viewMode)); + const largestTabCount = tabs.reduce((largest, tab) => Math.max(largest, tab.count), 0); + return Math.max(1, largestTabCount || fallback); +} + +function ClinicalNotesChecklistPanel({ + answer, + viewMode, + evidenceMapRows, + bestSource, + copied, + onCopy, + onOpenTables, +}: { + answer: RagAnswer; + viewMode: AnswerViewMode; + evidenceMapRows: AnswerEvidenceMapRow[]; + bestSource: BestSourceRecommendation | null; + copied: boolean; + onCopy: () => void; + onOpenTables?: () => void; +}) { + const detailSections = clinicalNotesDetailSectionsForAnswer(answer, viewMode); + const tabs = clinicalNotesAvailableTabs(detailSections); + const [requestedTab, setRequestedTab] = useState(tabs[0]?.id ?? "safety"); + const activeTab = tabs.some((tab) => tab.id === requestedTab) ? requestedTab : (tabs[0]?.id ?? "safety"); + const rows = clinicalNotesRowsForTab(detailSections, activeTab); + const tableEvidenceCount = clinicalNotesTableEvidenceCount(answer); + const [expandedRowId, setExpandedRowId] = useState(null); + const firstExpandableRow = rows.find(clinicalNoteHasDistinctDetail) ?? null; + const activeRow = rows.find((row) => row.id === expandedRowId) ?? firstExpandableRow; + const [added, setAdded] = useState(false); + const toggles: Array<{ + id: ClinicalNotesTabId | "table"; + label: string; + icon: typeof ShieldCheck; + count?: number; + popout?: boolean; + }> = [ + ...tabs.map((tab) => ({ ...tab, popout: false })), + ...(tableEvidenceCount > 0 && onOpenTables + ? [{ id: "table" as const, label: "Table", icon: Table2, count: tableEvidenceCount, popout: true }] + : []), + ]; + + if (!tabs.length || rows.length === 0) { + return ( + + ); + } + + const ActiveIcon = clinicalNotesTabMeta[activeTab].icon; + + return ( +
+
+
+ {toggles.map((tab) => { + const Icon = tab.icon; + const selected = !tab.popout && tab.id === activeTab; + return ( + + ); + })} +
+
+ +
+ +
+

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

+

+ {activeTab === "safety" + ? "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} +
+ ); + })} +
+ +
+
+ {bestSource ? ( + + + Source + + ) : ( + + + Source + + )} + + +
+
+
+ ); +} + function SafetyFindingsPanel({ findings }: { findings: ReturnType }) { if (findings.length === 0) return null; @@ -2138,7 +2552,7 @@ function ClinicalOutputPanel({ .filter((section) => (showLead ? section.id !== leadSection?.id : section.id !== "bottom-line")) .map((section) => ({ ...section, - items: showLead ? section.items : section.items.filter((item) => !isRedundantStructuredItem(item, primaryAnswer)), + items: displayItemsForClinicalDetailSection(section, primaryAnswer, showLead), })) .filter((section) => section.items.length > 0 || Boolean(section.tables?.length)); const orderedDetailSections = sortClinicalDetailSections(detailSections); @@ -2617,6 +3031,7 @@ function MobileEvidenceSheetContent({ query, visualEvidence, answerEvidenceMapRows, + initialTab, pendingFeedback, copiedQuotes, onCopyQuotes, @@ -2629,6 +3044,7 @@ function MobileEvidenceSheetContent({ query: string; visualEvidence: VisualEvidenceCard[]; answerEvidenceMapRows: AnswerEvidenceMapRow[]; + initialTab?: EvidenceTabName | null; pendingFeedback: AnswerFeedbackType | null; copiedQuotes: boolean; onCopyQuotes: () => void; @@ -2638,7 +3054,7 @@ function MobileEvidenceSheetContent({ }) { const order = evidenceTabOrder(answer); const pdfSources = uniquePdfSources(answer).slice(0, 6); - const [selectedTab, setSelectedTab] = useState(null); + const [selectedTab, setSelectedTab] = useState(() => initialTab ?? null); const activeTab = selectedTab && order.includes(selectedTab) ? selectedTab : order[0]; const panelIdFor = (tab: EvidenceTabName) => `mobile-evidence-panel-${tab.toLowerCase()}`; @@ -3270,9 +3686,23 @@ function StagedAnswerResultSurface({ }) { const noteCount = clinicalNotesCount(answer); const showClinicalNotes = safetyFindings.length > 0 || noteCount > 0; + const clinicalNoteDisplayCount = clinicalNotesDisplayCountForAnswer( + answer, + answerViewMode, + noteCount || safetyFindings.length, + ); const sourceCount = sourceSummary?.total_sources ?? sources.length ?? answer.sources?.length ?? answer.citations.length; const centralTable = answerHasCentralTable(answer) ? primaryVisualTable(answer) : null; + const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); + const [evidenceOpen, setEvidenceOpen] = useState(false); + const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); + const openTableEvidence = useCallback(() => { + setClinicalNotesOpen(false); + setEvidenceInitialTab("Tables"); + setEvidenceOpen(true); + }, [setClinicalNotesOpen, setEvidenceInitialTab, setEvidenceOpen]); + return (
@@ -3311,19 +3741,54 @@ function StagedAnswerResultSurface({ {showClinicalNotes ? ( + + + } + sheetTitleAccessory={ + + {clinicalNoteDisplayCount} + + } + sheetDescriptionContent={ + + + Source-backed + + } + sheetHeaderActions={ + bestSource ? ( + + + + ) : null + } + sheetDescription={null} + 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" > - ) : null} @@ -3335,6 +3800,11 @@ function StagedAnswerResultSurface({ summary={compactEvidenceSummary(answer, sources, sourceSummary)} mobileSummary={compactEvidenceSummary(answer, sources, sourceSummary)} className={evidenceRow} + open={evidenceOpen} + onOpenChange={(open) => { + setEvidenceOpen(open); + if (!open) setEvidenceInitialTab(null); + }} >
undefined} @@ -3662,6 +4133,7 @@ function DocumentDrawer({ documents, pagination, loadingMoreDocuments, + mode, selectedDocumentIds, statusFilter, onToggleScope, @@ -3679,6 +4151,7 @@ function DocumentDrawer({ documents: ClinicalDocument[]; pagination: DocumentPagination | null; loadingMoreDocuments: boolean; + mode: DocumentDrawerMode; selectedDocumentIds: string[]; statusFilter: DocumentDrawerStatusFilter; onToggleScope: (documentId: string) => void; @@ -3694,6 +4167,12 @@ function DocumentDrawer({ onTagSearch: (tag: SmartDocumentTag) => void; }) { const [filter, setFilter] = useState(""); + const [selectedType, setSelectedType] = useState("all"); + const [selectedSite, setSelectedSite] = useState("all"); + const [selectedTopic, setSelectedTopic] = useState("all"); + const [selectedPopulation, setSelectedPopulation] = useState("all"); + const [showNeedsReviewOnly, setShowNeedsReviewOnly] = useState(false); + const [collectionDraft, setCollectionDraft] = useState(""); const [metadataDraft, setMetadataDraft] = useState({ sourceStatus: "", @@ -3705,22 +4184,147 @@ function DocumentDrawer({ sourceType: "", category: "", }); - const filtered = documents.filter((document) => { - if (!documentStatusMatchesFilter(document, statusFilter)) return false; - const labelText = tagSearchText(document); - const summaryText = document.summary?.summary ?? ""; - const haystack = `${document.title} ${document.file_name} ${labelText} ${summaryText}`.toLowerCase(); - return haystack.includes(filter.toLowerCase()); - }); - const visibleStatusLabel = statusFilterLabel(statusFilter); + + const allTypes = useMemo(() => { + const types = new Set(); + for (const doc of documents) { + const typeLabel = doc.labels?.find((l) => l.label_type === "document_type" && l.confidence >= 0.5)?.label; + if (typeLabel) types.add(typeLabel); + const profile = documentOrganizationProfile(doc); + if (profile?.document_type?.label && profile.document_type.label !== "unknown") { + types.add(profile.document_type.label); + } + } + return Array.from(types).sort(); + }, [documents]); + + const allSites = useMemo(() => { + const sites = new Set(); + for (const doc of documents) { + const siteLabels = doc.labels?.filter((l) => l.label_type === "site" && l.confidence >= 0.5) ?? []; + for (const l of siteLabels) sites.add(l.label); + const profile = documentOrganizationProfile(doc); + if (profile?.site?.label) sites.add(profile.site.label); + } + return Array.from(sites).sort(); + }, [documents]); + + const allTopics = useMemo(() => { + const topics = new Set(); + for (const doc of documents) { + const topicLabels = + doc.labels?.filter((l) => (l.label_type === "topic" || l.label_type === "custom") && l.confidence >= 0.5) ?? []; + for (const l of topicLabels) topics.add(l.label); + const profile = documentOrganizationProfile(doc); + if (profile?.secondary_facets?.topic) { + for (const t of profile.secondary_facets.topic) topics.add(t); + } + } + return Array.from(topics).sort(); + }, [documents]); + + const allPopulations = useMemo(() => { + const populations = new Set(); + for (const doc of documents) { + const popLabels = doc.labels?.filter((l) => l.label_type === "population" && l.confidence >= 0.5) ?? []; + for (const l of popLabels) populations.add(l.label); + const profile = documentOrganizationProfile(doc); + if (profile?.secondary_facets?.population) { + for (const p of profile.secondary_facets.population) populations.add(p); + } + } + return Array.from(populations).sort(); + }, [documents]); + + const isAdminMode = mode === "admin" && canManageDocuments; + const modeLabel = + mode === "recent" + ? "Recent documents" + : mode === "source" + ? "Source PDFs" + : mode === "admin" + ? statusFilterLabel(statusFilter) + : "Source library"; + const modeSummary = + mode === "recent" + ? "Recently updated indexed sources." + : mode === "source" + ? "PDF source documents ready to open." + : mode === "admin" + ? "Document maintenance and indexing tools." + : "Search and open indexed clinical sources."; + const filterValue = filter.toLowerCase(); + + const filtered = documents + .filter((document) => { + if (!documentStatusMatchesFilter(document, statusFilter)) return false; + if (mode === "source") { + const typeText = `${document.file_type} ${document.file_name}`.toLowerCase(); + if (!typeText.includes("pdf")) return false; + } + + // Filter by Type + if (selectedType !== "all") { + const typeLabel = document.labels?.find((l) => l.label_type === "document_type" && l.confidence >= 0.5)?.label; + const profile = documentOrganizationProfile(document); + const hasTypeMatch = typeLabel === selectedType || profile?.document_type?.label === selectedType; + if (!hasTypeMatch) return false; + } + + // Filter by Site + if (selectedSite !== "all") { + const siteLabels = document.labels?.filter((l) => l.label_type === "site" && l.confidence >= 0.5) ?? []; + const profile = documentOrganizationProfile(document); + const hasSiteMatch = siteLabels.some((l) => l.label === selectedSite) || profile?.site?.label === selectedSite; + if (!hasSiteMatch) return false; + } + + // Filter by Topic + if (selectedTopic !== "all") { + const topicLabels = + document.labels?.filter( + (l) => (l.label_type === "topic" || l.label_type === "custom") && l.confidence >= 0.5, + ) ?? []; + const profile = documentOrganizationProfile(document); + const hasTopicMatch = + topicLabels.some((l) => l.label === selectedTopic) || + profile?.secondary_facets?.topic?.includes(selectedTopic); + if (!hasTopicMatch) return false; + } + + // Filter by Population + if (selectedPopulation !== "all") { + const popLabels = document.labels?.filter((l) => l.label_type === "population" && l.confidence >= 0.5) ?? []; + const profile = documentOrganizationProfile(document); + const hasPopMatch = + popLabels.some((l) => l.label === selectedPopulation) || + profile?.secondary_facets?.population?.includes(selectedPopulation); + if (!hasPopMatch) return false; + } + + // Filter by Needs Review + if (showNeedsReviewOnly) { + const profile = documentOrganizationProfile(document); + if (profile?.review_status !== "needs_review") return false; + } + + const labelText = tagSearchText(document); + const summaryText = document.summary?.summary ?? ""; + const haystack = `${document.title} ${document.file_name} ${labelText} ${summaryText}`.toLowerCase(); + return haystack.includes(filterValue); + }) + .sort((left, right) => { + if (mode !== "recent") return 0; + return new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime(); + }); return (
-

{visibleStatusLabel}

+

{modeLabel}

- {filtered.length} matching document{filtered.length === 1 ? "" : "s"} + {modeSummary} {filtered.length} matching document{filtered.length === 1 ? "" : "s"}.

@@ -3729,18 +4333,107 @@ function DocumentDrawer({ setFilter(event.target.value)} - placeholder="Find a document" + placeholder={mode === "source" ? "Find a source PDF" : "Find a document"} className={fieldControlWithIcon} /> + + {/* Dynamic Browse Library Filters */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Admin Queue Toggle */} + {isAdminMode ? ( +
+ setShowNeedsReviewOnly(e.target.checked)} + className="rounded border-[color:var(--border)] text-[color:var(--primary)] focus:ring-[color:var(--primary)] h-4 w-4" + /> + +
+ ) : null} {pagination && pagination.total > documents.length ? (

Showing {documents.length} of {pagination.total} documents. Load more to manage older files.

) : null} - - - {selectedDocumentIds.length ? ( + {isAdminMode ? : null} + {isAdminMode ? : null} + {isAdminMode && selectedDocumentIds.length ? (
@@ -3934,12 +4627,14 @@ function DocumentDrawer({
- + {isAdminMode ? ( + + ) : null} - + + + {tabMenuOpen ? ( +
+ {favouriteTabs.map((tab) => { + const Icon = tab.icon; + const selected = selectedTab === tab.id; + const count = favouriteTypeCount(tab.id); + return ( + + ); + })} +
+ ) : null} +
+ {normalizedQuery ? ( + + ) : null} + {selectedSet ? ( + + ) : null} +
+
+ + +
+
+ +
+
+

Saved sets

+ +
+
+ {visibleSets.slice(0, 3).map((set) => ( + { + setSelectedSetId(set.id); + if (selectedTab === "sets") setSelectedTab("all"); + }} + /> + ))} +
+
+ +
+
+
+
+

+ {selectedTab === "all" + ? "Recent favourites" + : selectedTab === "sets" + ? "Saved sets" + : `${selectedTabLabel} favourites`} +

+

+ {selectedTab === "sets" ? "Open a focused clinical set." : "Open, ask, copy, or organise saved items."} +

+
+ + {selectedTab === "sets" ? visibleSets.length : visibleItems.length} + +
+ +
+ {showSets && selectedTab === "sets" + ? visibleSets.map((set) => ( + { + setSelectedSetId(set.id); + setSelectedTab("all"); + }} + /> + )) + : null} + + {showItems + ? visibleItems.map((item) => ( + setSelectedTab("sets")} /> + )) + : null} + + {empty ? ( +
+
+ +

No favourites match

+

+ Clear the composer text or choose another tab. +

+
+
+ ) : null} +
+
+ + +
+
+ ); +} + +function FavouriteItemRow({ item, onMoveToSet }: { item: FavouriteItem; onMoveToSet: () => void }) { + const Icon = item.icon; + return ( +
+ + + +
+

{item.title}

+

{item.meta}

+
+ {item.set} + + {item.sourceMeta} + +
+
+
+ + +
+ +
+ ); +} + +function FavouriteSetRow({ + favouriteSet, + compact = false, + selected = false, + onSelect, +}: { + favouriteSet: FavouriteSet; + compact?: boolean; + selected?: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +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 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; + + 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 + +
+
+ + {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"; + + return ( +
+
+ + + + + {toolStatusLabel(tool)} + +
+ +
+

{tool.title}

+

+ {tool.description} +

+
+ {tool.category} + + {tool.target === "internal" ? "In-app" : "Connected"} + +
+
+ + + {cleanToolHrefLabel(tool.href)} + +
+
+ + {disabled ? ( + {tool.disabledHint ?? "Unavailable"} + ) : tool.target === "internal" ? ( + + Launch + + + ) : ( + + Launch + + + )} +
+ ); + })} +
+ )} +
+ ); +} + +type MobileSectionFabItem = { + label: string; + description: string; + icon: typeof FileText; + href: (typeof navigationHashes)[number]; count: number | null; empty?: boolean; }; @@ -4956,18 +6452,37 @@ function buildMobileSectionFabState({ governanceWarningCount, }: { hasAnswer: boolean; - searchMode: SearchMode; + searchMode: AppModeId; sourceCount: number; quoteCount: number; weakEvidence: boolean; governanceWarningCount: number; }): MobileSectionFabState { + const modeSearch = appModeSearchConfig(searchMode); if (!hasAnswer) { + if (modeSearch.resultKind === "favourites") { + return { + statusLabel: "Favourites", + statusTone: "neutral", + nextStep: "Browse saved items", + badgeLabel: null, + badgeTone: "neutral", + }; + } + if (modeSearch.resultKind === "tools") { + return { + statusLabel: "Tools", + statusTone: "neutral", + nextStep: "Launch a clinical tool", + badgeLabel: null, + badgeTone: "neutral", + }; + } return { - statusLabel: searchMode === "documents" ? "Document search" : "No answer yet", + statusLabel: modeSearch.resultKind === "documents" ? "Document search" : "No answer yet", statusTone: "empty", - nextStep: searchMode === "documents" ? "Review matching documents" : "Ask a question first", - badgeLabel: searchMode === "documents" ? null : "?", + nextStep: modeSearch.nextStep, + badgeLabel: modeSearch.badgeLabel, badgeTone: "empty", }; } @@ -5336,15 +6851,23 @@ function mergeDocumentRefresh(current: ClinicalDocument[], updates: ClinicalDocu }); } -export function ClinicalDashboard() { +export function ClinicalDashboard({ + initialSearchMode = "answer", + initialQuery = "", + focusSearch = false, +}: { initialSearchMode?: AppModeId; initialQuery?: string; focusSearch?: boolean } = {}) { + const router = useRouter(); const mainRef = useRef(null); + const composerInputRef = useRef(null); const scrollFrameRef = useRef(null); const navSyncLockRef = useRef(null); const refreshInFlightRef = useRef | null>(null); const nextWorkStatePollRef = useRef(0); const urlSearchBootstrappedRef = useRef(false); + const urlDocumentSearchBootstrappedRef = useRef(false); const [documents, setDocuments] = useState([]); const [documentsPagination, setDocumentsPagination] = useState(null); + const indexedDocumentTotal = documentsPagination?.total ?? documents.length; const [dashboardDataLoading, setDashboardDataLoading] = useState(true); const [loadingMoreDocuments, setLoadingMoreDocuments] = useState(false); const [jobs, setJobs] = useState([]); @@ -5352,14 +6875,17 @@ export function ClinicalDashboard() { const [qualityItems, setQualityItems] = useState([]); const jobsRef = useRef(jobs); const batchesRef = useRef(batches); - const [query, setQuery] = useState(""); - const [searchMode, setSearchMode] = useState("answer"); + const [query, setQuery] = useState(initialQuery); + const [searchMode, setSearchMode] = useState(initialSearchMode); const [answer, setAnswer] = useState(null); const [sources, setSources] = useState([]); const [documentMatches, setDocumentMatches] = useState([]); const [searchRelevance, setSearchRelevance] = useState(null); const [searchFacets, setSearchFacets] = useState(null); const [queryMode, setQueryMode] = useState("auto"); + const activeModeSearch = appModeSearchConfig(searchMode); + const activeModeResultKind = appModeResultKind(searchMode); + const requestQueryMode = appModeQueryMode(searchMode, queryMode); const [scopeFilters, setScopeFilters] = useState({}); const [searchScope, setSearchScope] = useState(null); const [sourceGovernanceWarnings, setSourceGovernanceWarnings] = useState([]); @@ -5384,6 +6910,7 @@ export function ClinicalDashboard() { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); + const [documentsDrawerMode, setDocumentsDrawerMode] = useState("library"); const [uploadDrawerOpen, setUploadDrawerOpen] = useState(false); const [uploadMobileTab, setUploadMobileTab] = useState("upload"); const [documentDrawerStatusFilter, setDocumentDrawerStatusFilter] = useState("indexed"); @@ -5405,12 +6932,20 @@ export function ClinicalDashboard() { const storedSessionExists = typeof window !== "undefined" && Object.keys(localStorage).some((k) => k.startsWith("sb-") && k.endsWith("-auth-token")); + const localDevCanAttemptPrivateApis = process.env.NODE_ENV !== "production" && hasReadyPublicSearchSetup(setupChecks); const canUsePrivateApis = localProjectReady && - (localNoAuthMode || authStatus === "authenticated" || (supabaseEnvStatus === "ready" && storedSessionExists)); + (localNoAuthMode || + localDevCanAttemptPrivateApis || + authStatus === "authenticated" || + (supabaseEnvStatus === "ready" && storedSessionExists)); const canRunSearch = explicitDemoMode || (hasReadyPublicSearchSetup(setupChecks) && canUsePrivateApis); const openGuide = useCallback(() => setGuideOpen(true), []); const closeGuide = useCallback(() => setGuideOpen(false), []); + const prefetchApplications = useCallback(() => { + router.prefetch("/applications"); + void import("@/components/applications-launcher-page"); + }, [router]); const openLibraryHealthTarget = useCallback((target: LibraryHealthTarget) => { const targetId = target === "documents" @@ -5421,6 +6956,7 @@ export function ClinicalDashboard() { if (target === "documents") { setDocumentDrawerStatusFilter("indexed"); + setDocumentsDrawerMode("admin"); setDocumentsDrawerOpen(true); } else if (target === "indexing") { setUploadMobileTab("jobs"); @@ -5441,6 +6977,11 @@ export function ClinicalDashboard() { }, 0); }, []); + useEffect(() => { + const timeoutId = window.setTimeout(prefetchApplications, 250); + return () => window.clearTimeout(timeoutId); + }, [prefetchApplications]); + useEffect(() => { let cancelled = false; const frame = window.requestAnimationFrame(() => { @@ -5897,16 +7438,43 @@ export function ClinicalDashboard() { }, []); useEffect(() => { - if (urlSearchBootstrappedRef.current || !canRunSearch) return; + if (!focusSearch) return undefined; + focusComposerInput(); + const timeout = window.setTimeout(focusComposerInput, 500); + return () => window.clearTimeout(timeout); + }, [focusSearch]); + + useEffect(() => { + if (urlSearchBootstrappedRef.current) return; const params = new URLSearchParams(window.location.search); const mode = params.get("mode"); const searchText = params.get("q")?.trim(); - if (mode !== "documents") return; + const shouldFocusComposer = params.get("focus") === "1"; + if (!isAppModeId(mode) || !isAppModeVisible(mode)) return; urlSearchBootstrappedRef.current = true; - const frame = window.requestAnimationFrame(() => setSearchMode("documents")); - if (searchText) void runDocumentSearchShortcut(searchText, scopeFilters, false); + const targetMode = mode; + const frame = window.requestAnimationFrame(() => { + setSearchMode(targetMode); + if (searchText) setQuery(searchText); + if (shouldFocusComposer) focusComposerInput(); + }); return () => window.cancelAnimationFrame(frame); - // URL bootstrap intentionally runs once when search setup becomes available. + }, []); + + useEffect(() => { + if (urlDocumentSearchBootstrappedRef.current) return; + const params = new URLSearchParams(window.location.search); + const mode = params.get("mode"); + const searchText = params.get("q")?.trim(); + if (!searchText || !isAppModeId(mode) || !isAppModeVisible(mode)) return; + if (mode === "prescribing") return; + const modeSearch = appModeSearchConfig(mode); + const shouldRun = params.get("run") === "1" || modeSearch.kind === "documents"; + if (!shouldRun) return; + if (modeSearch.kind !== "favourites" && modeSearch.kind !== "tools" && !canRunSearch) return; + urlDocumentSearchBootstrappedRef.current = true; + void executeSearch(searchText, mode, scopeFilters); + // URL search intentionally runs once when the selected mode can execute. // eslint-disable-next-line react-hooks/exhaustive-deps }, [canRunSearch]); @@ -5943,7 +7511,11 @@ export function ClinicalDashboard() { ); } - async function requestDocuments(queryText: string, filtersOverride?: SearchScopeFilters) { + async function requestDocuments( + queryText: string, + filtersOverride?: SearchScopeFilters, + queryModeOverride: ClinicalQueryMode = requestQueryMode, + ) { let response: Response; try { response = await fetch("/api/search", { @@ -5957,7 +7529,7 @@ export function ClinicalDashboard() { mode: "documents", documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined, filters: compactScopeFilters(filtersOverride ?? scopeFilters), - queryMode, + queryMode: queryModeOverride, documentLimit: 30, topK: 20, }), @@ -5991,7 +7563,11 @@ export function ClinicalDashboard() { }; } - async function requestAnswer(queryText: string) { + async function requestAnswer( + queryText: string, + filtersOverride: SearchScopeFilters = scopeFilters, + queryModeOverride: ClinicalQueryMode = requestQueryMode, + ) { let response: Response; try { response = await fetch("/api/answer/stream", { @@ -6003,8 +7579,8 @@ export function ClinicalDashboard() { body: JSON.stringify({ query: queryText, documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined, - filters: compactScopeFilters(scopeFilters), - queryMode, + filters: compactScopeFilters(filtersOverride), + queryMode: queryModeOverride, }), }); } catch { @@ -6089,14 +7665,31 @@ export function ClinicalDashboard() { if (answerData.demoMode) setDemoMode(true); } - async function ask() { - const trimmedQuery = query.trim(); + async function executeSearch(searchText: string, targetMode: AppModeId = searchMode, filtersOverride = scopeFilters) { + const trimmedQuery = searchText.trim(); if (!trimmedQuery) return; + const modeSearch = appModeSearchConfig(targetMode); + const targetQueryMode = appModeQueryMode(targetMode, queryMode); + + setSearchMode(targetMode); + setQuery(trimmedQuery); + + if (modeSearch.kind === "favourites") { + setError(null); + rememberRecentQuery(trimmedQuery); + setActionNotice({ tone: "success", message: "Favourites filtered from the composer." }); + return; + } + if (modeSearch.kind === "tools") { + setError(null); + rememberRecentQuery(trimmedQuery); + setActionNotice({ tone: "success", message: "Tools filtered from the composer." }); + return; + } if (!canRunSearch) { setError("Search setup not ready."); return; } - setLoading(true); setError(null); setSearchRelevance(null); @@ -6104,7 +7697,7 @@ export function ClinicalDashboard() { setSearchScope(null); setSourceGovernanceWarnings([]); setAnswerViewMode("high_yield"); - setAnswerProgress(searchMode === "documents" ? "Finding matching documents." : "Searching indexed documents."); + setAnswerProgress(modeSearch.progressLabel); rememberRecentQuery(trimmedQuery); const fallbackQuery = keywordQueryFromNaturalLanguage(trimmedQuery); @@ -6125,9 +7718,9 @@ export function ClinicalDashboard() { try { const payload = - searchMode === "documents" - ? await runWithRetries(() => requestDocuments(entry.query)) - : await runWithRetries(() => requestAnswer(entry.query)); + modeSearch.kind === "documents" + ? await runWithRetries(() => requestDocuments(entry.query, filtersOverride, targetQueryMode)) + : await runWithRetries(() => requestAnswer(entry.query, filtersOverride, targetQueryMode)); if (!resultUsable(payload)) { lastError = makeSearchError("No usable results were found.", 404, false); @@ -6162,6 +7755,35 @@ export function ClinicalDashboard() { } } + function setMedicationSearchQuery(searchText: string, updateUrl = true) { + const trimmedSearchText = searchText.trim(); + if (!trimmedSearchText) return; + setSearchMode("prescribing"); + setQuery(trimmedSearchText); + setLoading(false); + setError(null); + setAnswerProgress(null); + rememberRecentQuery(trimmedSearchText); + window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); + if (updateUrl) router.replace(appModeHomeHref("prescribing", { query: trimmedSearchText })); + } + + async function ask() { + if (searchMode === "prescribing") { + setMedicationSearchQuery(query); + return; + } + await executeSearch(query, searchMode, scopeFilters); + } + + function pickRecentQuery(recentQuery: string) { + if (searchMode === "prescribing") { + setMedicationSearchQuery(recentQuery); + return; + } + setQuery(recentQuery); + } + async function submitAnswerFeedback(feedbackType: AnswerFeedbackType) { if (!answer || pendingFeedback) return; if (clientDemoMode) { @@ -6243,14 +7865,16 @@ export function ClinicalDashboard() { window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); } - function updateDocumentSearchUrl(searchText: string) { - const params = new URLSearchParams(window.location.search); - params.set("mode", "documents"); - params.set("q", searchText); - window.history.replaceState(null, "", `/?${params.toString()}`); + function updateDocumentSearchUrl(searchText: string, mode: AppModeId = "documents") { + window.history.replaceState(null, "", appModeHomeHref(mode, { query: searchText })); } - async function runDocumentSearchShortcut(searchText: string, filtersOverride = scopeFilters, updateUrl = true) { + async function runDocumentSearchShortcut( + searchText: string, + filtersOverride = scopeFilters, + updateUrl = true, + targetMode: AppModeId = "documents", + ) { const trimmedSearchText = searchText.trim(); if (!trimmedSearchText) return; if (!canRunSearch) { @@ -6259,10 +7883,10 @@ export function ClinicalDashboard() { } setQuery(trimmedSearchText); - setSearchMode("documents"); + setSearchMode(targetMode); setLoading(true); setError(null); - setAnswerProgress("Finding matching documents."); + setAnswerProgress(appModeSearchConfig(targetMode).progressLabel); setSearchRelevance(null); setSearchFacets(null); setSearchScope(null); @@ -6270,10 +7894,13 @@ export function ClinicalDashboard() { setAnswerViewMode("high_yield"); rememberRecentQuery(trimmedSearchText); window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); - if (updateUrl) updateDocumentSearchUrl(trimmedSearchText); + if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode); try { - const payload = await runWithRetries(() => requestDocuments(trimmedSearchText, filtersOverride)); + const shortcutQueryMode = appModeQueryMode(targetMode, queryMode); + const payload = await runWithRetries(() => + requestDocuments(trimmedSearchText, filtersOverride, shortcutQueryMode), + ); applySearchResult(payload); } catch (requestError) { setError(requestError instanceof Error ? requestError.message : "Document search failed"); @@ -6356,8 +7983,25 @@ export function ClinicalDashboard() { } } + function selectSearchMode(mode: AppModeId) { + setSearchMode(mode); + router.replace(appModeHomeHref(mode)); + } + + function focusComposerInput() { + window.requestAnimationFrame(() => { + composerInputRef.current?.focus({ preventScroll: true }); + window.setTimeout(() => composerInputRef.current?.focus({ preventScroll: true }), 150); + }); + } + function startNewChat() { + const href = appModeHomeHref("answer", { focus: true }); setQuery(""); + setSearchMode("answer"); + setQueryMode("auto"); + setSelectedDocumentIds([]); + setScopeFilters({}); setAnswer(null); setSources([]); setDocumentMatches([]); @@ -6368,11 +8012,46 @@ export function ClinicalDashboard() { setError(null); setAnswerProgress(null); setAnswerViewMode("high_yield"); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); + router.replace(href); + window.requestAnimationFrame(() => { + mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }); + focusComposerInput(); + } + + function openDocumentsDrawer(mode: DocumentDrawerMode) { + setSearchMode("documents"); + setDocumentDrawerStatusFilter("indexed"); + setDocumentsDrawerMode(mode); + setDocumentsDrawerOpen(true); + window.requestAnimationFrame(() => { + document.getElementById("dashboard-documents-drawer")?.scrollIntoView({ block: "start", behavior: "smooth" }); + }); + } + + function openRecentDocuments() { + openDocumentsDrawer("recent"); + } + + function openSourceLibrary() { + openDocumentsDrawer("library"); + } + + function openSourcePdfBrowser() { + openDocumentsDrawer("source"); } function openUploadDrawer() { + if (!canUsePrivateApis) { + openDocumentsDrawer("library"); + setActionNotice({ + tone: "warning", + message: "Upload and indexing tools are admin-only. Use the source library to open indexed documents.", + }); + return; + } setSearchMode("documents"); + setDocumentsDrawerMode("admin"); setUploadDrawerOpen(true); window.requestAnimationFrame(() => { const drawer = document.getElementById("dashboard-upload-drawer") as HTMLDetailsElement | null; @@ -6560,21 +8239,43 @@ export function ClinicalDashboard() { ); const bottomNavItems = [ { - label: searchMode === "answer" ? "Answer" : "Docs", + label: activeModeSearch.statusLabel, description: - searchMode === "answer" - ? answer - ? weakEvidence - ? "Read synthesis carefully" - : "Clinical synthesis" - : "Ask a question first" - : documentMatches.length - ? "Document results" - : "Search documents", - icon: searchMode === "answer" ? Search : FileText, + activeModeResultKind === "favourites" + ? query.trim() + ? "Filtered favourites" + : "Browse saved items" + : activeModeResultKind === "tools" + ? query.trim() + ? "Filtered tools" + : "Browse tools" + : activeModeResultKind === "answer" + ? answer + ? weakEvidence + ? "Read synthesis carefully" + : "Clinical synthesis" + : activeModeSearch.nextStep + : documentMatches.length + ? "Document results" + : activeModeSearch.readyTitle, + icon: + activeModeResultKind === "favourites" + ? Heart + : activeModeResultKind === "tools" + ? Wrench + : activeModeResultKind === "answer" + ? Search + : FileText, href: "#search", - count: searchMode === "documents" ? documentMatches.length : null, - empty: searchMode === "documents" && documentMatches.length === 0, + count: + activeModeResultKind === "favourites" + ? favouriteItems.length + favouriteSets.length + : activeModeResultKind === "tools" + ? toolCatalog.length + : activeModeResultKind === "documents" + ? documentMatches.length + : null, + empty: activeModeResultKind === "documents" && documentMatches.length === 0, }, { label: "Quotes", @@ -6693,6 +8394,36 @@ export function ClinicalDashboard() { setUploadMobileTab("jobs"); void refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); }; + const documentsDrawerIsAdmin = documentsDrawerMode === "admin" && canUsePrivateApis; + const documentsDrawerTitle = + documentsDrawerMode === "recent" + ? "Recent documents" + : documentsDrawerMode === "source" + ? "Source PDFs" + : documentsDrawerIsAdmin + ? "Document admin" + : "Source library"; + const documentsDrawerSummary = dashboardDataLoading + ? "Loading indexed document status." + : indexedDocumentTotal + ? documentsDrawerMode === "recent" + ? `${indexedDocumentTotal.toLocaleString()} indexed sources, sorted by recent updates` + : documentsDrawerMode === "source" + ? "Open original PDF source documents" + : documentsDrawerIsAdmin + ? `${indexedDocumentTotal.toLocaleString()} indexed documents available` + : `${indexedDocumentTotal.toLocaleString()} indexed sources available` + : "No indexed documents yet."; + const documentsDrawerMobileSummary = dashboardDataLoading + ? "Loading library" + : documentsDrawerMode === "recent" + ? "Recent sources" + : documentsDrawerMode === "source" + ? "PDF sources" + : documentsDrawerIsAdmin + ? "Admin" + : "Library"; + const drawerGroupTitle = uploadDrawerOpen || documentsDrawerIsAdmin ? "Library and admin" : "Sources"; return (
setQuery("")} onClearScope={() => setSelectedDocumentIds([])} @@ -6741,6 +8474,8 @@ export function ClinicalDashboard() { onOpenMobileSidebar={() => setMobileSidebarOpen(true)} onToggleTheme={toggleTheme} queryModeOptions={clinicalQueryModeOptions} + queryInputRef={composerInputRef} + queryInputAutoFocus={focusSearch} />

- {searchMode === "answer" ? "Answer" : "Document matches"} + {activeModeSearch.resultHeading}

{error && (
)} - {loading && answerProgress && ( + {loading && answerProgress && searchMode !== "prescribing" && (
)} - {searchMode === "documents" ? ( - <> - - setQuery("")} + onAddFavourite={() => + setActionNotice({ + tone: "success", + message: "Favourite actions are ready for the selected answer, medication, or source.", + }) + } + /> + ) : activeModeResultKind === "tools" ? ( + setQuery("")} /> + ) : activeModeResultKind === "documents" ? ( + searchMode === "prescribing" ? ( + - + ) : ( + <> + + + + ) ) : loading && !answer ? ( ) : answer ? ( @@ -6868,7 +8635,7 @@ export function ClinicalDashboard() { {showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} - {searchMode === "answer" && answer && ( + {activeModeResultKind === "answer" && answer && ( - - - - - - - - -
+ {documentsDrawerOpen ? ( + - {uploadTabs.map((tab) => { - const active = uploadMobileTab === tab.id; - const Icon = tab.icon; - return ( - - ); - })} -
-
-
-

- Developer setup status -

- - {showAuthPanel && } -
-
-

Clinical upload

- -
-
-

- Indexing progress -

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

- Ingestion quality console -

- + {uploadTabs.map((tab) => { + const active = uploadMobileTab === tab.id; + const Icon = tab.icon; + return ( + + ); + })}
-
-
+
+
+

+ Developer setup status +

+ + {showAuthPanel && } +
+
+

+ Clinical upload +

+ +
+
+

+ Indexing progress +

+ +
+
+

+ Ingestion quality console +

+ +
+
+ + ) : null}
)} @@ -7075,8 +8839,9 @@ export function ClinicalDashboard() { recentQueries={recentQueries} onOpenChange={setMobileSidebarOpen} onNewChat={startNewChat} - onPickRecent={setQuery} + onPickRecent={pickRecentQuery} onOpenGuide={openGuide} + onPrefetchApplications={prefetchApplications} /> diff --git a/src/components/DashboardFloatingFab.tsx b/src/components/DashboardFloatingFab.tsx index 98aa73a048..7b16a19440 100644 --- a/src/components/DashboardFloatingFab.tsx +++ b/src/components/DashboardFloatingFab.tsx @@ -92,12 +92,12 @@ export function DashboardFloatingFab() { Copy link setOpen(false)} className={cn(floatingControl, "h-9 min-h-9 px-3 text-xs", !open && "hidden")} > - Tools + Applications {copyNotice && (

diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index bc630635ea..453c688b09 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2087,9 +2087,7 @@ export function DocumentViewer({ ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}` : documentHomeHref; const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; - const summarizeTitle = canSummarizeDocument - ? "Answer from this document" - : "Load a source document before answering"; + const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering"; const selectedPage = pages.find((page) => page.page_number === initialPage) ?? pages[0]; const selectedChunk = chunkId ? chunks.find((chunk) => chunk.id === chunkId) : undefined; const clinicalImages = images.filter( diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx new file mode 100644 index 0000000000..39ba7b6483 --- /dev/null +++ b/src/components/applications-launcher-page.tsx @@ -0,0 +1,1162 @@ +"use client"; + +import Link from "next/link"; +import { + BookOpen, + Brain, + Check, + ChevronRight, + ClipboardList, + ExternalLink, + FileText, + Grid2X2, + Globe2, + ListChecks, + Mic, + Menu, + MoreVertical, + Pill, + Pin, + PinOff, + Plus, + Puzzle, + Search, + Send, + Settings, + Sparkles, + Star, + Stethoscope, + Users, + X, + type LucideIcon, +} from "lucide-react"; +import { type FormEvent, useMemo, useState } from "react"; + +import { Sheet } from "@/components/ui/sheet"; +import { + chatComposerIconButton, + chatComposerInput, + chatComposerShell, + chatSendButton, + cn, + sidebarItem, + sidebarToolTile as sidebarApplicationTile, + textMuted, +} from "@/components/ui-primitives"; +import { toolCatalog } from "@/lib/tools"; + +type LauncherStatus = "ready" | "recent" | "review_due"; +type LauncherCategory = "clinical" | "admin" | "recent"; + +type LauncherApp = { + id: string; + title: string; + shortTitle?: string; + description: string; + detail: string; + href: string; + external: boolean; + icon: LucideIcon; + category: LauncherCategory; + workflow: string; + lastUsed: string; + status: LauncherStatus; + sourceToolId?: string; + relatedIds: string[]; + quickActions: string[]; + recentWorkflows: Array<{ title: string; date: string }>; +}; + +const statusStyles: Record = { + ready: "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]", + recent: "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]", + review_due: "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]", +}; + +const statusLabels: Record = { + ready: "Ready", + recent: "Recent", + review_due: "Review due", +}; + +const filterOptions = [ + { id: "all", label: "All" }, + { id: "clinical", label: "Clinical" }, + { id: "admin", label: "Admin" }, + { id: "recent", label: "Recent" }, +] as const; + +const sidebarApplicationItems = [ + { label: "Formulation", icon: Brain, href: "/applications" }, + { label: "DSM-5", icon: BookOpen, href: "/applications" }, + { label: "Meds", icon: Pill, href: "/?mode=prescribing" }, + { label: "Diffs", icon: Search, href: "/applications" }, +] as const; + +function toolById(id: string) { + return toolCatalog.find((tool) => tool.id === id); +} + +function hrefForTool(id: string, fallback: string) { + return toolById(id)?.href ?? fallback; +} + +function isExternalTool(id: string) { + return toolById(id)?.target === "external"; +} + +const launcherApps: LauncherApp[] = [ + { + id: "differential-diagnosis", + title: "Differential Diagnosis", + description: "Generate and explore differential diagnoses.", + detail: "Compare likely differentials, rule-outs, red flags, and competing diagnostic explanations.", + href: hrefForTool("differentials", "http://127.0.0.1:53375"), + external: isExternalTool("differentials"), + icon: Stethoscope, + category: "recent", + workflow: "Assessment", + lastUsed: "Today, 10:24 AM", + status: "recent", + sourceToolId: "differentials", + relatedIds: ["specifiers", "services", "clinical-kb-search"], + quickActions: ["Start differential", "Review red flags", "Compare rule-outs", "Open assessment history"], + recentWorkflows: [ + { title: "Chest pain differential", date: "Today, 10:24 AM" }, + { title: "Headache workup", date: "Yesterday, 3:11 PM" }, + { title: "Fatigue assessment", date: "May 10, 2025" }, + ], + }, + { + id: "specifiers", + title: "Specifiers", + description: "Add clinical specifiers and refine conditions.", + detail: "Review severity, course, qualifiers, and specifier language for a diagnosis.", + href: hrefForTool("specifiers", "http://127.0.0.1:58123"), + external: isExternalTool("specifiers"), + icon: ListChecks, + category: "clinical", + workflow: "Assessment", + lastUsed: "Yesterday, 4:15 PM", + status: "ready", + sourceToolId: "specifiers", + relatedIds: ["differential-diagnosis", "formulation", "clinical-kb-search"], + quickActions: ["Open specifier review", "Check course descriptors", "Review severity", "Browse qualifiers"], + recentWorkflows: [ + { title: "Mood episode specifiers", date: "Yesterday, 4:15 PM" }, + { title: "Anxiety course review", date: "May 11, 2025" }, + { title: "Psychosis qualifiers", date: "May 8, 2025" }, + ], + }, + { + id: "services", + title: "Services", + description: "Browse and manage clinical services.", + detail: "Find referral pathways, access points, service matching, and destination options.", + href: hrefForTool("services", "http://127.0.0.1:53174"), + external: isExternalTool("services"), + icon: Users, + category: "clinical", + workflow: "Care planning", + lastUsed: "May 12, 2025", + status: "ready", + sourceToolId: "services", + relatedIds: ["formulation", "documents", "clinical-kb-search"], + quickActions: ["Find referral pathway", "Browse service options", "Review access criteria", "Open saved pathway"], + recentWorkflows: [ + { title: "Community referral options", date: "May 12, 2025" }, + { title: "Crisis pathway review", date: "May 9, 2025" }, + { title: "Outpatient access check", date: "May 7, 2025" }, + ], + }, + { + id: "formulation", + title: "Formulation", + description: "Create and manage clinical formulations.", + detail: "Structure case formulations to support assessment, conceptualisation, care planning, and team reuse.", + href: hrefForTool("formulation", "http://localhost:53210"), + external: isExternalTool("formulation"), + icon: Puzzle, + category: "recent", + workflow: "Care planning", + lastUsed: "Today, 9:02 AM", + status: "recent", + sourceToolId: "formulation", + relatedIds: ["differential-diagnosis", "medication-prescribing", "documents", "clinical-kb-search"], + quickActions: ["Create new formulation", "Browse my formulations", "Shared with me", "Formulation templates"], + recentWorkflows: [ + { title: "Mood & anxiety formulation - Jane D.", date: "Today, 9:02 AM" }, + { title: "Complex case review - M. Smith", date: "May 12, 2025" }, + { title: "Care plan formulation - P. Johnson", date: "May 9, 2025" }, + { title: "Discharge formulation - K. Patel", date: "May 7, 2025" }, + ], + }, + { + id: "medication-prescribing", + title: "Medication Prescribing", + shortTitle: "Medication", + description: "Search, prescribe and review medications.", + detail: "Check prescribing context, monitoring, interactions, and medication-specific safety issues.", + href: "/?mode=prescribing", + external: false, + icon: Pill, + category: "clinical", + workflow: "Care planning", + lastUsed: "May 12, 2025", + status: "review_due", + sourceToolId: "medications", + relatedIds: ["formulation", "documents", "clinical-kb-search"], + quickActions: ["Create new prescription", "Browse formulary", "Review interactions", "Medication templates"], + recentWorkflows: [ + { title: "Medication review - Sam T.", date: "May 12, 2025" }, + { title: "Repeat prescription - Atorvastatin 20mg", date: "May 9, 2025" }, + { title: "Renal dose screen", date: "May 8, 2025" }, + ], + }, + { + id: "documents", + title: "Documents", + description: "Access and manage clinical documents.", + detail: "Search indexed PDFs, guidelines, policies, notes, and source documents.", + href: "/?mode=documents", + external: false, + icon: FileText, + category: "admin", + workflow: "Reference", + lastUsed: "May 10, 2025", + status: "ready", + relatedIds: ["clinical-kb-search", "favourites", "formulation"], + quickActions: ["Search documents", "Browse library", "Open source PDF", "Review indexed documents"], + recentWorkflows: [ + { title: "Lithium monitoring guideline", date: "May 10, 2025" }, + { title: "Safety plan source review", date: "May 9, 2025" }, + { title: "Clozapine monitoring protocol", date: "May 7, 2025" }, + ], + }, + { + id: "favourites", + title: "Favourites", + description: "View and manage your saved items.", + detail: "Open saved sources, medications, documents, workflows, and reusable clinical sets.", + href: "/?mode=favourites", + external: false, + icon: Star, + category: "recent", + workflow: "Reference", + lastUsed: "Today, 8:45 AM", + status: "recent", + relatedIds: ["documents", "clinical-kb-search", "formulation"], + quickActions: ["Open saved items", "Manage pinned sets", "Review due favourites", "Add current answer"], + recentWorkflows: [ + { title: "Ward round set", date: "Today, 8:45 AM" }, + { title: "Prescribing safety set", date: "May 12, 2025" }, + { title: "Document QA set", date: "May 10, 2025" }, + ], + }, + { + id: "clinical-kb-search", + title: "Clinical KB Search", + description: "Search the knowledge base content.", + detail: "Search the Clinical KB for source-backed guidance, answers, and evidence.", + href: "/?mode=answer", + external: false, + icon: Search, + category: "clinical", + workflow: "Reference", + lastUsed: "Today, 7:30 AM", + status: "ready", + relatedIds: ["documents", "favourites", "differential-diagnosis"], + quickActions: ["Ask clinical question", "Search indexed guidelines", "Open document scope", "Review sources"], + recentWorkflows: [ + { title: "Lithium monitoring search", date: "Today, 7:30 AM" }, + { title: "Safety plan search", date: "May 12, 2025" }, + { title: "Clozapine source check", date: "May 10, 2025" }, + ], + }, +]; + +const seedPinnedIds = ["formulation", "differential-diagnosis", "medication-prescribing"]; + +const recentActivity = [ + { id: "formulation", label: "Formulation opened", date: "Today, 9:02 AM", icon: Puzzle }, + { + id: "differential-diagnosis", + label: "Differential Diagnosis launched", + date: "Today, 10:24 AM", + icon: Stethoscope, + }, + { id: "medication-prescribing", label: "Medication Prescribing reviewed", date: "May 12, 2025", icon: Pill }, + { id: "documents", label: "Documents opened", date: "May 10, 2025", icon: FileText }, +] as const; + +function appById(id: string) { + return launcherApps.find((app) => app.id === id) ?? launcherApps[0]; +} + +function StatusPill({ status }: { status: LauncherStatus }) { + return ( + + {statusLabels[status]} + + ); +} + +function AppIcon({ app, compact = false }: { app: LauncherApp; compact?: boolean }) { + const Icon = app.icon; + return ( + + + + ); +} + +function LaunchLink({ app, compact = false, className }: { app: LauncherApp; compact?: boolean; className?: string }) { + const label = `Launch ${app.title}`; + const classes = cn( + "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[color:var(--clinical-chat-teal)] px-4 text-sm font-semibold text-white shadow-[var(--shadow-tight)] hover:bg-[color:var(--primary-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", + compact && "min-h-9 px-3 text-xs", + className, + ); + + if (app.external) { + return ( + + Launch + + + ); + } + + return ( + + Launch + + + ); +} + +function HeaderFilter({ + activeFilter, + onFilterChange, +}: { + activeFilter: (typeof filterOptions)[number]["id"]; + onFilterChange: (filter: (typeof filterOptions)[number]["id"]) => void; +}) { + return ( +

+ {filterOptions.map((option) => { + const active = option.id === activeFilter; + return ( + + ); + })} +
+ ); +} + +function PinnedSection({ + pinnedApps, + selectedId, + onSelect, + onTogglePin, +}: { + pinnedApps: LauncherApp[]; + selectedId: string; + onSelect: (id: string) => void; + onTogglePin: (id: string) => void; +}) { + return ( +
+
+
+ + Pinned +
+ {pinnedApps.length} pinned +
+
+ {pinnedApps.map((app) => { + const selected = selectedId === app.id; + return ( +
+ + + +
+ ); + })} +
+
+ ); +} + +function ApplicationRow({ + app, + selected, + pinned, + onSelect, + onTogglePin, +}: { + app: LauncherApp; + selected: boolean; + pinned: boolean; + onSelect: (id: string) => void; + onTogglePin: (id: string) => void; +}) { + return ( +
+ + + +
+ ); +} + +function MobileApplicationRow({ + app, + selected, + onSelect, +}: { + app: LauncherApp; + selected: boolean; + onSelect: (id: string) => void; +}) { + return ( + + ); +} + +function DetailPanel({ + app, + pinned, + onTogglePin, + onClose, + headingId, + testId = "selected-application-panel", + variant = "inline", +}: { + app: LauncherApp; + pinned: boolean; + onTogglePin: (id: string) => void; + onClose?: () => void; + headingId?: string; + testId?: string; + variant?: "inline" | "sheet"; +}) { + const related = app.relatedIds.map(appById); + + return ( + + ); +} + +function ApplicationsMobileMenu({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const recentQueries = ["lithium", "tables", "dosing for lithium", "management of bulimia nervosa"]; + + return ( + onOpenChange(false)} + title="Clinical Guide" + description="Recent chats, applications, help, and settings." + closeLabel="Close Clinical Guide menu" + placement="left" + contentClassName="lg:hidden" + > +
+ onOpenChange(false)} + className="inline-flex min-h-[44px] w-full items-center justify-center gap-2 rounded-lg bg-[color:var(--clinical-chat-teal)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-tight)] hover:bg-[color:var(--primary-strong)]" + > + + New chat + + + + +
+

+ Recent chats +

+
+ {recentQueries.map((recent) => ( + + ))} +
+
+ +
+

+ Applications +

+
+ {sidebarApplicationItems.map((item) => { + const Icon = item.icon; + return ( + onOpenChange(false)} + className={sidebarApplicationTile} + > + + {item.label} + + ); + })} +
+
+ +
+ + +
+ + AK + + + Dr A. Khan + Ready + +
+
+
+
+ ); +} + +function ApplicationsModeMenu({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const options = [ + { + label: "Answer", + description: "Source-backed clinical answer", + href: "/?mode=answer", + icon: Sparkles, + active: false, + }, + { + label: "Documents", + description: "Search indexed PDFs and notes", + href: "/?mode=documents", + icon: FileText, + active: false, + }, + { + label: "Favourites", + description: "Saved sources and workflows", + href: "/?mode=favourites", + icon: Star, + active: false, + }, + { + label: "Applications", + description: "Launch connected applications", + href: "/applications", + icon: Grid2X2, + active: true, + }, + ] as const; + + if (!open) return null; + + return ( +
+ {options.map((option) => { + const Icon = option.icon; + return ( + onOpenChange(false)} + aria-current={option.active ? "page" : undefined} + className={cn( + "grid min-h-[3.25rem] w-full grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2.5 py-2 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", + option.active + ? "bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)]" + : "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]", + )} + > + + + + + {option.label} + + {option.description} + + + {option.active ? : null} + + ); + })} +
+ ); +} + +function ApplicationsHeader({ + mobileMenuOpen, + modeMenuOpen, + onMobileMenuOpenChange, + onModeMenuOpenChange, +}: { + mobileMenuOpen: boolean; + modeMenuOpen: boolean; + onMobileMenuOpenChange: (open: boolean) => void; + onModeMenuOpenChange: (open: boolean) => void; +}) { + return ( + + ); +} + +export function ApplicationsLauncherPage() { + const [query, setQuery] = useState(""); + const [activeFilter, setActiveFilter] = useState<(typeof filterOptions)[number]["id"]>("all"); + const [selectedId, setSelectedId] = useState("formulation"); + const [pinnedIds, setPinnedIds] = useState(seedPinnedIds); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [modeMenuOpen, setModeMenuOpen] = useState(false); + const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + + const normalizedQuery = query.trim().toLowerCase(); + const pinnedApps = pinnedIds.map(appById); + const selectedApp = appById(selectedId); + + const filteredApps = useMemo(() => { + return launcherApps.filter((app) => { + const matchesFilter = + activeFilter === "all" || + (activeFilter === "recent" + ? app.status === "recent" || app.category === "recent" + : app.category === activeFilter); + const matchesQuery = + !normalizedQuery || + [app.title, app.description, app.workflow, app.detail].some((value) => + value.toLowerCase().includes(normalizedQuery), + ); + return matchesFilter && matchesQuery; + }); + }, [activeFilter, normalizedQuery]); + + function togglePin(id: string) { + setPinnedIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [id, ...current])); + } + + function selectApplication(id: string) { + setSelectedId(id); + if (typeof window !== "undefined" && window.matchMedia("(max-width: 1023px)").matches) { + setMobileDetailOpen(true); + } + } + + function submitFooterSearch(event: FormEvent) { + event.preventDefault(); + const firstMatch = filteredApps[0]; + if (firstMatch) selectApplication(firstMatch.id); + } + + return ( +
+ + + +
+
+ + + +

+ Applications +

+

+ Open the clinical applications and connected workflows you use for assessment, formulation, prescribing, + documents, and saved workflows. +

+
+ +
+
+ +
+
+ + +
+
+ +

All applications

+
+ +
+ Application + Last used + Status + Action + +
+ +
+ {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 ( + + ); + })} +
+
+
+ +
+ +
+
+
+ + setMobileDetailOpen(false)} + labelledBy="selected-application-sheet-heading" + closeLabel="Close selected application" + contentClassName="lg:hidden rounded-t-[1.75rem] bg-[color:var(--surface-lux)]" + bodyClassName="px-5 pb-6 pt-4 sm:px-5" + portal + > + setMobileDetailOpen(false)} + headingId="selected-application-sheet-heading" + testId="selected-application-sheet-panel" + variant="sheet" + /> + + +
+ + + + +
+
+ ); +} diff --git a/src/components/clinical-dashboard/dashboard-shell.tsx b/src/components/clinical-dashboard/dashboard-shell.tsx index b710deb450..5a9a573858 100644 --- a/src/components/clinical-dashboard/dashboard-shell.tsx +++ b/src/components/clinical-dashboard/dashboard-shell.tsx @@ -1,7 +1,7 @@ "use client"; import { BookOpen, ChevronDown, type LucideIcon } from "lucide-react"; -import { ReactNode, useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { Sheet } from "@/components/ui/sheet"; import { @@ -76,6 +76,14 @@ export function UtilityDrawer({ onOpenChange, className, mobileInline = false, + sheetHeaderLeading, + sheetTitleAccessory, + sheetDescriptionContent, + sheetHeaderActions, + sheetContentClassName, + sheetContentStyle, + sheetBodyClassName, + sheetDescription, }: { id?: string; title: string; @@ -88,10 +96,23 @@ export function UtilityDrawer({ onOpenChange?: (open: boolean) => void; className?: string; mobileInline?: boolean; + sheetHeaderLeading?: ReactNode; + sheetTitleAccessory?: ReactNode; + sheetDescriptionContent?: ReactNode; + sheetHeaderActions?: ReactNode; + sheetContentClassName?: string; + sheetContentStyle?: CSSProperties; + sheetBodyClassName?: string; + sheetDescription?: string | null; }) { const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const [usesSheet, setUsesSheet] = useState(false); + const mobileTriggerRef = useRef(null); const open = controlledOpen ?? uncontrolledOpen; + const triggerClassName = cn( + "flex min-h-[56px] w-full cursor-pointer list-none items-center justify-between gap-3 rounded-lg px-4 py-3 text-left transition motion-safe:duration-150 hover:bg-[color:var(--surface-subtle)]", + className, + ); const setOpen = useCallback( (nextOpen: boolean) => { if (controlledOpen === undefined) setUncontrolledOpen(nextOpen); @@ -111,16 +132,12 @@ export function UtilityDrawer({ return ( <> + ); + })} + + +

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

); } @@ -302,6 +430,63 @@ function SearchResultsHeader({ resultLabel, trimmedQuery }: { resultLabel: strin ); } +function DocumentResultsOverview({ + documentCount, + displayedCount, + matchCount, + activeFacetCount, + trimmedQuery, + onOpenLibrary, +}: { + documentCount: number; + displayedCount: number; + matchCount: number; + activeFacetCount: number; + trimmedQuery: string; + onOpenLibrary: () => void; +}) { + return ( +
+
+

Documents overview

+
+ + {documentCount.toLocaleString()} indexed + + + {matchCount.toLocaleString()} match{matchCount === 1 ? "" : "es"} + + {activeFacetCount > 0 ? ( + + {displayedCount.toLocaleString()} after filters + + ) : null} + {trimmedQuery ? ( + + {trimmedQuery} + + ) : null} +
+
+ +
+ ); +} + export function MatchExplanationChips({ source }: { source: SearchResult }) { const explanation = source.match_explanation; const reasons = explanation?.reasons?.length @@ -347,6 +532,9 @@ export function DocumentSearchResultsPanel({ facets: _facets, onScopeDocument, onAnswerFromDocument, + onOpenRecentDocuments, + onOpenLibrary, + onOpenSourcePdf, onTagSearch, }: { matches: DocumentMatch[]; @@ -360,6 +548,9 @@ export function DocumentSearchResultsPanel({ facets?: SearchFacets | null; onScopeDocument: (documentId: string) => void; onAnswerFromDocument: (documentId: string) => void; + onOpenRecentDocuments: () => void; + onOpenLibrary: () => void; + onOpenSourcePdf: () => void; onTagSearch: (tag: SmartDocumentTag | SmartDocumentTagFacet) => void; }) { void _facets; @@ -442,10 +633,23 @@ export function DocumentSearchResultsPanel({ ) : ( - + ) ) : ( <> + {resultTabs.length > 1 ? (
{resultTabs.map((tab) => { @@ -501,82 +705,84 @@ export function DocumentSearchResultsPanel({ key={document.document_id} className={cn( sourceCard, - "relative overflow-hidden p-0 shadow-[0_10px_24px_rgb(15_27_45_/_5%)]", - index === 0 && "border-l-4 border-l-[color:var(--clinical-chat-teal)]", + "relative overflow-visible p-0 shadow-[0_8px_18px_rgb(15_27_45_/_4%)] transition hover:border-[color:var(--clinical-chat-teal-border)] hover:shadow-[0_14px_32px_rgb(15_27_45_/_7%)]", + index === 0 && "ring-1 ring-[color:var(--clinical-chat-teal)]/15", )} > -
- +
+
-

- {documentKindLabel(document)} +

+ {documentKindLabel(document)} + {index === 0 ? ( + <> +

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

+ {evidenceBadges.length ? ( + {evidenceBadges.map((badge) => badge.label).join(", ")} + ) : null} +

-
+
+ - Open + {contextualOpenLabel(document)} onScopeDocument(document.document_id)} icon={Filter} - className="min-h-12 border-r border-[color:var(--border)] text-sm text-[color:var(--text)]" + className="min-h-11 rounded-lg px-2.5 text-xs text-[color:var(--text)]" aria-label={`Scope search to ${document.title}`} > Scope @@ -584,7 +790,7 @@ export function DocumentSearchResultsPanel({ onAnswerFromDocument(document.document_id)} icon={Sparkles} - className="min-h-12 text-sm text-[color:var(--clinical-chat-teal)] hover:bg-[color:var(--clinical-chat-teal-soft)]" + className="ml-auto min-h-11 rounded-lg px-2.5 text-xs text-[color:var(--clinical-chat-teal)] hover:bg-[color:var(--clinical-chat-teal-soft)]" aria-label={`Answer from ${document.title}`} > Answer diff --git a/src/components/clinical-dashboard/document-ui.tsx b/src/components/clinical-dashboard/document-ui.tsx index ee3e573876..565c7dca98 100644 --- a/src/components/clinical-dashboard/document-ui.tsx +++ b/src/components/clinical-dashboard/document-ui.tsx @@ -11,9 +11,9 @@ export type DocumentTileTone = "teal" | "info"; const badgeStyles: Record = { best: "border-[color:var(--clinical-chat-teal)]/20 bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]", - high: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]", + high: "border-[color:var(--clinical-chat-teal)]/18 bg-[color:var(--surface-raised)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]", relevant: "border-[color:var(--info)]/15 bg-[color:var(--info-soft)]/70 text-[color:var(--info)]", - neutral: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]", + neutral: "border-[color:var(--border-lux)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]", }; const tileStyles: Record = { diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx new file mode 100644 index 0000000000..10390b6727 --- /dev/null +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { FileText, Heart, ListChecks, Pill, Search, Sparkles, Wrench } from "lucide-react"; +import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react"; + +import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { useTheme } from "@/components/clinical-dashboard/use-theme"; +import { Sheet } from "@/components/ui/sheet"; +import { cn, sidebarItem } from "@/components/ui-primitives"; +import { + appModeDefinition, + appModeHomeHref, + isAppModeId, + isAppModeVisible, + visibleAppModeDefinitions, + type AppModeId, +} from "@/lib/app-modes"; +import type { SearchScopeFilters } from "@/lib/search-scope"; +import type { ClinicalQueryMode } from "@/lib/types"; + +const mockupQueryModeOptions: Array<{ value: ClinicalQueryMode; label: string }> = [ + { value: "auto", label: "Auto" }, + { value: "monitoring_schedule", label: "Monitoring" }, + { value: "dose_threshold_lookup", label: "Dose / thresholds" }, + { value: "contraindications_cautions", label: "Cautions" }, + { value: "escalation_criteria", label: "Escalation" }, + { value: "required_documentation", label: "Documentation" }, + { value: "compare_guidance", label: "Compare" }, +]; + +const appModeIcons: Record = { + answer: Sparkles, + documents: FileText, + prescribing: Pill, + evidence: ListChecks, + favourites: Heart, + tools: Wrench, +}; + +export function GlobalMockupSearchShell({ children }: { children: ReactNode }) { + const router = useRouter(); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [searchMode, setSearchMode] = useState("answer"); + const [queryMode, setQueryMode] = useState("auto"); + const [scopeFilters, setScopeFilters] = useState({}); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const { theme, toggleTheme } = useTheme(); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + const params = new URLSearchParams(window.location.search); + const requestedMode = params.get("mode"); + if (isAppModeId(requestedMode) && isAppModeVisible(requestedMode)) setSearchMode(requestedMode); + + const requestedQuery = params.get("q")?.trim(); + if (requestedQuery) setQuery(requestedQuery); + + if (params.get("focus") === "1") inputRef.current?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(frame); + }, []); + + function navigateToMode(mode: AppModeId, options: { query?: string; run?: boolean; focus?: boolean } = {}) { + router.push(appModeHomeHref(mode, options)); + } + + function submitSearch() { + const trimmedQuery = query.trim(); + navigateToMode(searchMode, { + query: trimmedQuery || undefined, + run: Boolean(trimmedQuery), + focus: true, + }); + } + + function changeMode(mode: AppModeId) { + setSearchMode(mode); + setMobileMenuOpen(false); + navigateToMode(mode, { focus: true }); + } + + function startNewChat() { + setQuery(""); + setSearchMode("answer"); + setMobileMenuOpen(false); + navigateToMode("answer", { focus: true }); + } + + return ( +
+ setQuery("")} + onClearScope={() => undefined} + onQueryModeChange={setQueryMode} + onScopeFiltersChange={setScopeFilters} + onToggleScope={() => undefined} + onOpenUpload={() => router.push(`${appModeHomeHref("documents", { focus: true })}#sources`)} + onOpenEvidence={() => navigateToMode("evidence", { focus: true })} + onNewChat={startNewChat} + onOpenMobileSidebar={() => setMobileMenuOpen(true)} + onToggleTheme={toggleTheme} + queryModeOptions={mockupQueryModeOptions} + scopeVariant="placeholder" + queryInputRef={inputRef} + modeAlignment="center" + /> + +
+ {children} +
+ + setMobileMenuOpen(false)} + title="Clinical Guide" + description="Choose the search workspace." + closeLabel="Close Clinical Guide menu" + placement="left" + contentClassName="max-w-[min(20rem,calc(100vw-1rem))]" + > + + +
+ ); +} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index e19a1bdd9f..68089c97d8 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1,8 +1,10 @@ "use client"; -import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState, type Ref } from "react"; import { + Activity, + CalendarDays, Check, CheckCircle2, ChevronDown, @@ -19,10 +21,12 @@ import { Plus, Search, Send, + ShieldCheck, Sparkles, Sun, - UserRound, X, + Lock, + Wrench, } from "lucide-react"; import { DocumentTagCloud } from "@/components/DocumentTagCloud"; @@ -37,6 +41,14 @@ import { eyebrowText, } from "@/components/ui-primitives"; import { Sheet } from "@/components/ui/sheet"; +import { + appModeDefinition, + appModeDefinitions, + appModeSearchConfig, + isSearchableAppMode, + visibleAppModeDefinitions, + type AppModeId, +} from "@/lib/app-modes"; import { type ResolvedTheme } from "@/lib/theme"; import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; @@ -44,64 +56,15 @@ import { tagSearchText } from "@/lib/document-tags"; const mobileSheetMediaQuery = "(max-width: 639px)"; -type AppModeId = "answer" | "documents" | "prescribing" | "evidence" | "favourites" | "profile"; - -const appModeOptions: Array<{ - id: AppModeId; - label: string; - description: string; - icon: typeof Search; - href?: string; - devOnly?: boolean; -}> = [ - { - id: "answer", - label: "Answer", - description: "Source-backed clinical answer", - icon: Sparkles, - }, - { - id: "documents", - label: "Documents", - description: "Search indexed PDFs and notes", - icon: FileText, - }, - { - id: "prescribing", - label: "Prescribing", - description: "Medication checks and guidance", - icon: Pill, - href: "/mockups/medication-prescribing", - devOnly: true, - }, - { - id: "evidence", - label: "Evidence", - description: "Tables, quotes, images, PDFs", - icon: ListChecks, - href: "/mockups/answer-evidence-popups", - devOnly: true, - }, - { - id: "favourites", - label: "Favourites", - description: "Saved sources and workflows", - icon: Heart, - href: "/mockups/favourites-hub", - devOnly: true, - }, - { - id: "profile", - label: "Profile", - description: "Home, preferences, review queue", - icon: UserRound, - href: "/mockups/user-home-profile", - devOnly: true, - }, -]; - -const isDev = process.env.NODE_ENV === "development"; -const visibleAppModeOptions = appModeOptions.filter((mode) => !mode.devOnly || isDev); +const visibleAppModeOptions = visibleAppModeDefinitions(); +const appModeIcons: Record = { + answer: Sparkles, + documents: FileText, + prescribing: Pill, + evidence: ListChecks, + favourites: Heart, + tools: Wrench, +}; function splitFilterText(value: string) { return value @@ -128,6 +91,7 @@ function documentScopeMeta(document: ClinicalDocument) { export function MasterSearchHeader({ documents, + documentTotal, query, searchMode, loading, @@ -151,10 +115,15 @@ export function MasterSearchHeader({ onOpenMobileSidebar, onToggleTheme, queryModeOptions, + scopeVariant = "full", + queryInputRef, + queryInputAutoFocus = false, + modeAlignment = "default", }: { documents: ClinicalDocument[]; + documentTotal?: number; query: string; - searchMode: "answer" | "documents"; + searchMode: AppModeId; loading: boolean; selectedDocumentIds: string[]; queryMode: ClinicalQueryMode; @@ -162,7 +131,7 @@ export function MasterSearchHeader({ realDataReady: boolean; theme: ResolvedTheme; onQueryChange: (query: string) => void; - onSearchModeChange: (mode: "answer" | "documents") => void; + onSearchModeChange: (mode: AppModeId) => void; onAsk: () => void; onClearQuery: () => void; onClearScope: () => void; @@ -176,9 +145,23 @@ export function MasterSearchHeader({ onOpenMobileSidebar?: () => void; onToggleTheme: () => void; queryModeOptions: Array<{ value: ClinicalQueryMode; label: string }>; + scopeVariant?: "full" | "placeholder"; + queryInputRef?: Ref; + queryInputAutoFocus?: boolean; + modeAlignment?: "default" | "center"; }) { const trimmedQuery = query.trim(); - const canAsk = trimmedQuery.length >= 1 && !loading && realDataReady; + const selectedSearch = appModeSearchConfig(searchMode); + const selectedAppMode = appModeDefinition(searchMode); + const selectedSearchable = isSearchableAppMode(searchMode); + const scopeIsPlaceholder = scopeVariant === "placeholder"; + const canRunLocalSearch = selectedSearch.kind === "favourites" || selectedSearch.kind === "tools"; + const canAsk = trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch); + const indexedDocumentTotal = documentTotal ?? documents.length; + const hasUnloadedDocuments = indexedDocumentTotal > documents.length; + const loadedScopeSummary = hasUnloadedDocuments + ? `${documents.length.toLocaleString()} loaded of ${indexedDocumentTotal.toLocaleString()}` + : `${documents.length.toLocaleString()} available`; const [scopeFilter, setScopeFilter] = useState(""); const [scopeOpen, setScopeOpen] = useState(false); const [scopeSheetOpen, setScopeSheetOpen] = useState(false); @@ -186,6 +169,7 @@ export function MasterSearchHeader({ 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 scopeDetailsRef = useRef(null); @@ -222,18 +206,59 @@ export function MasterSearchHeader({ const hiddenScopeMatchCount = requireScopeFilter ? Math.max(0, selectedDocuments.length ? documents.length - selectedDocumentIds.length : documents.length) : Math.max(0, matchingDocuments.length - visibleScopeDocuments.length); - const submitLabel = searchMode === "answer" ? (trimmedQuery ? "Answer" : "Ask") : "Docs"; - const queryPlaceholder = searchMode === "documents" ? "Search documents..." : "Ask a clinical question..."; - const selectedAppMode = appModeOptions.find((mode) => mode.id === searchMode) ?? appModeOptions[0]; - const SelectedAppModeIcon = selectedAppMode.icon; - const dailyActions = [ - { label: "Search library", icon: Search }, - { label: "Add document", icon: FileText }, - { label: "Scope", icon: Filter }, - { label: "Evidence", icon: ListChecks }, - { label: "Clinical tools", icon: Sparkles }, - ] as const; + 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."; + + function currentUsesScopeSheet() { + return window.matchMedia(mobileSheetMediaQuery).matches; + } + function runDailyAction(label: (typeof dailyActions)[number]["label"]) { + if (searchMode === "prescribing") { + 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; + } + } if (label === "Search library") { onSearchModeChange("documents"); return; @@ -243,7 +268,9 @@ export function MasterSearchHeader({ return; } if (label === "Scope") { - if (usesScopeSheet) { + const nextUsesScopeSheet = currentUsesScopeSheet(); + setUsesScopeSheet(nextUsesScopeSheet); + if (nextUsesScopeSheet) { setScopeSheetOpen(true); } else { setScopeOpen(true); @@ -256,16 +283,16 @@ export function MasterSearchHeader({ onOpenEvidence?.(); return; } - window.location.assign("/tools"); + onSearchModeChange("tools"); } - function selectAppMode(mode: (typeof appModeOptions)[number]) { + function selectAppMode(mode: (typeof appModeDefinitions)[number]) { setModeMenuOpen(false); - if (mode.id === "answer" || mode.id === "documents") { + if (isSearchableAppMode(mode.id)) { onSearchModeChange(mode.id); return; } - if (mode.href) window.location.assign(mode.href); + if ("href" in mode && mode.href) window.location.assign(mode.href); } const collectionOptions = useMemo(() => { const values = new Set(); @@ -305,6 +332,31 @@ 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; @@ -452,7 +504,7 @@ export function MasterSearchHeader({

Document scope

- {selectedDocumentIds.length ? `${selectedDocumentIds.length} selected` : `${documents.length} available`} + {selectedDocumentIds.length ? `${selectedDocumentIds.length} selected` : loadedScopeSummary}
@@ -495,7 +547,8 @@ export function MasterSearchHeader({
{requireScopeFilter && visibleScopeDocuments.length === 0 ? (

- Type to filter {documents.length} documents. Selected documents stay pinned here. + Type to filter {documents.length.toLocaleString()} loaded documents. Selected documents stay pinned + here.

) : null} {visibleScopeDocuments.map((document) => { @@ -556,7 +609,7 @@ export function MasterSearchHeader({ {hiddenScopeMatchCount > 0 ? (

{requireScopeFilter - ? `${documents.length} documents available. Type a title or file name to narrow the list.` + ? `${loadedScopeSummary} documents. Type a title or file name to narrow the loaded list.` : `Showing ${visibleScopeDocuments.length} of ${matchingDocuments.length}. Keep typing to narrow the list.`}

) : null} @@ -572,7 +625,7 @@ export function MasterSearchHeader({ id="search" className="sticky top-0 z-30 border-b border-[color:var(--border)] bg-[color:var(--surface-lux)]/95 px-3 py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)] shadow-[var(--shadow-tight)] backdrop-blur-xl sm:px-4 lg:px-6" > -
+
-
+
+ ) : usesScopeSheet ? ( {dailyActionsOpen && !usesScopeSheet ? (
{dailyActions.map((item) => { @@ -797,12 +872,16 @@ export function MasterSearchHeader({