From 24d06a1a60589ca6d24ca0fc490738176b792ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 13:46:38 +0000 Subject: [PATCH 01/15] feat(navigation): one shared home page, mode-routed search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mode pill at the top of the screen was a navigation control: picking a mode ran router.push(appModeHomeHref(mode)) and replaced the whole page, so the app had 13 front doors and no single home. `/` is now the common home for every mode — the "How can I help?" hero with the composer. The pill no longer navigates; it only decides where the composer sends you on submit. Picking DSM keeps the page and swaps the placeholder; submitting lands on /dsm/search?q=… appModeHomeHref already mapped (mode, query) to the right search destination, so the routing table is essentially unchanged. Tools keeps opening its own home (/tools?q=…&run=1) because it has no search route — the requested exception falls out for free. Main changes: - Mode selection stops navigating. On the shared home it rewrites `?mode=` with history.replaceState (no push, so Back still leaves home) and lets the existing render-time URL sync own searchMode — never an optimistic set, per the hero-vs-dock rule in docs/search-chrome-behaviour.md. Off the home, a query in play is carried into the newly picked mode instead of dropped. - ask() gains a navigation branch. It previously navigated for documents only; every other mode ran executeSearch in place, which was safe only while `/` plus a namespaced mode was unreachable. That is now the normal state, so submitting in DSM would otherwise have run an in-dashboard search. - showAnswerHome becomes showSharedHome, gated on the pathname rather than the mode, and sits at the top of the mode-content chain. - Bare /?mode=X renders the home with X preselected; a submitted deep link (q plus run=1) still resolves to the mode's own search surface. - The last selected mode is remembered per browser, seeded only on a cold `/` visit so a shared or reloaded ?mode= link never flips after hydration. - Documents and Medication gain real homes at /documents and /medications. They were the only modes without their own route, so the shared home would otherwise have left their content unreachable. /medications was previously a 307 alias for /?mode=prescribing. - The sidebar gains a "More modes" group for the six modes it never listed, keeping every mode home reachable now that the pill does not open them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wa7MTVhAJMztFMMhzJf2J4 --- docs/design-system/ADOPTION.md | 8 +- docs/design-system/adoption-contract.json | 6 +- docs/design-system/adoption-manifest.json | 40 +++++- docs/site-map.md | 34 ++--- .../documents/documents-home-client.tsx | 8 ++ src/app/(search-app)/documents/page.tsx | 21 +++ .../medications/medications-home-client.tsx | 8 ++ src/app/(search-app)/medications/page.tsx | 21 +++ src/app/(search-app)/medications/route.ts | 18 --- src/app/(search-app)/page.tsx | 82 +++-------- src/components/ClinicalDashboard.tsx | 132 +++++++++++++---- .../clinical-dashboard/ClinicalSidebar.tsx | 83 ++++++++++- .../global-search-shell.tsx | 23 ++- .../master-search-header.tsx | 19 ++- .../clinical-dashboard/use-last-app-mode.ts | 82 +++++++++++ src/lib/app-modes.ts | 28 +++- src/lib/search-route-ownership.ts | 15 +- src/lib/search-shell-props.ts | 7 + src/lib/tools-catalog.ts | 4 +- tests/app-modes.test.ts | 44 ++++++ .../audit-navigation-auth-regressions.test.ts | 38 ++--- tests/design-system-adoption.test.ts | 4 +- tests/favourites-auth-gate.dom.test.tsx | 4 +- tests/mode-menu-prefetch.dom.test.tsx | 24 +++- tests/search-pins-menu.dom.test.tsx | 4 +- tests/search-results-band-adoption.test.ts | 20 ++- tests/search-route-ownership.test.ts | 54 ++++++- tests/site-map.test.ts | 4 +- tests/ui-route-coverage.spec.ts | 10 +- tests/ui-smoke.spec.ts | 79 ++++------- tests/ui-stress.spec.ts | 2 +- tests/ui-tools.spec.ts | 134 ++++++++++-------- 32 files changed, 741 insertions(+), 319 deletions(-) create mode 100644 src/app/(search-app)/documents/documents-home-client.tsx create mode 100644 src/app/(search-app)/documents/page.tsx create mode 100644 src/app/(search-app)/medications/medications-home-client.tsx create mode 100644 src/app/(search-app)/medications/page.tsx delete mode 100644 src/app/(search-app)/medications/route.ts create mode 100644 src/components/clinical-dashboard/use-last-app-mode.ts diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index e6b82e066c..d69d8dc759 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -325,12 +325,12 @@ product exclusions; the only route-only disposition is the documented legacy doc redirect. Shared shell/component roots carry their own explicit `shared-shell` disposition. Registered public components: 53 -Declared product roots: 55 +Declared product roots: 57 Roots with a literal `.ckb-v2` opt-in: 1 -Roots inheriting `.ckb-v2` from the global ``: 54 +Roots inheriting `.ckb-v2` from the global ``: 56 Production surfaces observed under v2: 14/14 Dynamic `ckb-v2` constructions: 0 -Declared production page routes: 47/47 +Declared production page routes: 49/49 Source observation and contract declaration are independent. A literal `ckb-v2` on the global `` makes every production surface inherit v2, but it does not approve that adoption. The Proof column summarizes each surface's dark, forced-colours, 320px, print and browser declarations; exact statuses and evidence paths live in the manifest. @@ -338,7 +338,7 @@ Observed v2 under a compatibility declaration fails closed. A declared v2 shell | Surface | Disposition | Routes | Roots | Declared shell | Observed shell (mount) | Proof | Baseline | | ---------------------------------- | --------------- | -----: | ----: | -------------- | -------------------------- | -------------- | -------------- | -| `root-shell-and-settings` | shared-shell | 1 | 4 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `root-shell-and-settings` | shared-shell | 3 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | | `catalogues-forms-and-info` | owned | 13 | 13 | v2 | v2 (inherited-global-root) | passed | not-committed | | `differentials` | owned | 4 | 4 | v2 | v2 (inherited-global-root) | passed | not-committed | | `formulation` | owned | 5 | 5 | v2 | v2 (inherited-global-root) | passed | not-committed | diff --git a/docs/design-system/adoption-contract.json b/docs/design-system/adoption-contract.json index fc9e4cd762..685fdcc2bd 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -70,7 +70,11 @@ { "id": "root-shell-and-settings", "disposition": "shared-shell", - "routes": ["src/app/(search-app)/page.tsx"], + "routes": [ + "src/app/(search-app)/page.tsx", + "src/app/(search-app)/documents/page.tsx", + "src/app/(search-app)/medications/page.tsx" + ], "routeRoots": true, "roots": [ "src/app/layout.tsx", diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 151c19f916..069596386a 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1892,7 +1892,11 @@ "id": "root-shell-and-settings", "disposition": "shared-shell", "documentedDisposition": "The root route, shared application shell, global overlays, answer orchestration and settings UI are owned by the mounted shell rather than separate settings routes.", - "routes": ["src/app/(search-app)/page.tsx"], + "routes": [ + "src/app/(search-app)/documents/page.tsx", + "src/app/(search-app)/medications/page.tsx", + "src/app/(search-app)/page.tsx" + ], "routeRoots": true, "proofApplicability": "required", "nonVisualRoute": null, @@ -1932,7 +1936,17 @@ "sanctionedSpecialPatterns": ["ClinicalDashboard", "OverlayRoot", "SettingsDialog"], "roots": [ { - "file": "src/app/(search-app)/page.tsx", + "file": "src/app/(search-app)/documents/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["ClinicalDashboard"] + }, + { + "file": "src/app/(search-app)/medications/page.tsx", "exists": true, "imports": [], "importedFamilies": [], @@ -1941,6 +1955,16 @@ "v2MountMode": "inherited-global-root", "sanctionedPatternsPresent": ["ClinicalDashboard"] }, + { + "file": "src/app/(search-app)/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, { "file": "src/app/layout.tsx", "exists": true, @@ -3138,6 +3162,7 @@ "src/app/(search-app)/differentials/page.tsx", "src/app/(search-app)/differentials/presentations/[slug]/page.tsx", "src/app/(search-app)/documents/[id]/page.tsx", + "src/app/(search-app)/documents/page.tsx", "src/app/(search-app)/documents/search/page.tsx", "src/app/(search-app)/documents/source/evidence/page.tsx", "src/app/(search-app)/documents/source/page.tsx", @@ -3158,6 +3183,7 @@ "src/app/(search-app)/formulation/map/page.tsx", "src/app/(search-app)/formulation/page.tsx", "src/app/(search-app)/medications/[slug]/page.tsx", + "src/app/(search-app)/medications/page.tsx", "src/app/(search-app)/page.tsx", "src/app/(search-app)/services/[slug]/page.tsx", "src/app/(search-app)/services/page.tsx", @@ -3187,6 +3213,7 @@ "src/app/(search-app)/differentials/page.tsx", "src/app/(search-app)/differentials/presentations/[slug]/page.tsx", "src/app/(search-app)/documents/[id]/page.tsx", + "src/app/(search-app)/documents/page.tsx", "src/app/(search-app)/documents/search/page.tsx", "src/app/(search-app)/documents/source/evidence/page.tsx", "src/app/(search-app)/documents/source/page.tsx", @@ -3207,6 +3234,7 @@ "src/app/(search-app)/formulation/map/page.tsx", "src/app/(search-app)/formulation/page.tsx", "src/app/(search-app)/medications/[slug]/page.tsx", + "src/app/(search-app)/medications/page.tsx", "src/app/(search-app)/page.tsx", "src/app/(search-app)/services/[slug]/page.tsx", "src/app/(search-app)/services/page.tsx", @@ -3235,7 +3263,7 @@ }, "adoption": { "literalCkbV2RootCount": 1, - "inheritedCkbV2RootCount": 54, + "inheritedCkbV2RootCount": 56, "dynamicCkbV2RootCount": 0, "v2MountedSurfaceCount": 14, "declaredV2SurfaceCount": 14 @@ -3244,8 +3272,8 @@ "registeredComponentCount": 53, "previewCount": 53, "productImportedComponentCount": 30, - "rootCount": 55, - "productionRouteCount": 47, - "nextUiEntryCount": 99 + "rootCount": 57, + "productionRouteCount": 49, + "nextUiEntryCount": 101 } } diff --git a/docs/site-map.md b/docs/site-map.md index 6da4767960..fe0045974d 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -9,6 +9,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/differentials` - Differentials home and search surface. Source: `src/app/(search-app)/differentials/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/(search-app)/differentials/diagnoses/page.tsx`. - `/differentials/presentations/[slug]` - Route discovered from app directory Source: `src/app/(search-app)/differentials/presentations/[slug]/page.tsx`. +- `/documents` - Route discovered from app directory Source: `src/app/(search-app)/documents/page.tsx`. - `/documents/search` - Documents search command centre. Source: `src/app/(search-app)/documents/search/page.tsx`. - `/documents/source` - Compatibility redirect to the canonical live document viewer when a valid id is supplied. Source: `src/app/(search-app)/documents/source/page.tsx`. - `/documents/source/evidence` - Compatibility redirect sharing the canonical live document viewer handoff. Source: `src/app/(search-app)/documents/source/evidence/page.tsx`. @@ -24,6 +25,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/formulation/builder` - Structured clinical formulation builder. Source: `src/app/(search-app)/formulation/builder/page.tsx`. - `/formulation/compare` - Side-by-side mechanism comparison. Source: `src/app/(search-app)/formulation/compare/page.tsx`. - `/formulation/map` - Formulation mechanism domain map. Source: `src/app/(search-app)/formulation/map/page.tsx`. +- `/medications` - Medication index redirect. Source: `src/app/(search-app)/medications/page.tsx`. - `/privacy` - Privacy and data-processing governance draft. Source: `src/app/privacy/page.tsx`. - `/reference/colour-coding` - Route discovered from app directory Source: `src/app/reference/colour-coding/page.tsx`. - `/safety-plan` - Patient safety plan generator (Stanley-Brown six steps) — a Tools-page clinical tool. Source: `src/app/safety-plan/page.tsx`. @@ -45,7 +47,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir ## Mode/query routes - `/?mode=answer` - Answer mode. Search kind: `answer`. Query example: `/?mode=answer&q=example+question&focus=1&run=1`. -- `/?mode=documents` - Documents mode. Search kind: `documents`. Query example: `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1`. +- `/documents` - Documents mode. Search kind: `documents`. Query example: `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1`. - `/services` - Services mode. Search kind: `services`. Query example: `/services?q=13YARN&focus=1&run=1`. - `/forms` - Forms mode. Search kind: `forms`. Query example: `/forms?q=transport+forms&focus=1&run=1`. - `/favourites` - Favourites mode. Search kind: `favourites`. Query example: `/favourites?q=clozapine+set&focus=1&run=1`. @@ -53,26 +55,26 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/dsm` - DSM-5 Diagnosis mode. Search kind: `dsm`. Query example: `/dsm/search?q=major+depressive+disorder&focus=1&run=1`. - `/specifiers` - Specifiers mode. Search kind: `specifiers`. Query example: `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1`. - `/formulation` - Formulation mode. Search kind: `formulation`. Query example: `/formulation?q=I+keep+going+over+it&focus=1&run=1`. -- `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. +- `/medications` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/tools` - Tools mode. Search kind: `tools`. Query example: `/tools?q=medications&focus=1&run=1`. - `/therapy-compass` - Therapy mode. Search kind: `tools`. Query example: `/therapy-compass?q=behavioural+activation&focus=1&run=1`. - `/factsheets` - Factsheets mode. Search kind: `tools`. Query example: `/factsheets/search?q=sertraline&focus=1&run=1`. ## Mode page index -| Mode | Home page | Search/results page | Information/detail pages | -| --------------- | -------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | -| Documents | `/?mode=documents` | `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/search` live results and `/documents/[id]` canonical viewer; `/documents/source*` are compatibility redirects. | -| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | -| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | -| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | -| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | -| DSM-5 Diagnosis | `/dsm` | `/dsm/search?q=major+depressive+disorder&focus=1&run=1` | `/dsm/diagnoses/[slug]`, `/dsm/compare`, and `/dsm/diagnoses/[slug]/differentials`. | -| Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | -| Formulation | `/formulation` | `/formulation?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | -| Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | -| Tools | `/tools` | `/tools?q=medications&focus=1&run=1` | Canonical Tools launcher at `/tools` (PT-11). `/?mode=tools` remains a dashboard-mode alias. | +| Mode | Home page | Search/results page | Information/detail pages | +| --------------- | ------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | +| Documents | `/?mode=documents` | `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/search` live results and `/documents/[id]` canonical viewer; `/documents/source*` are compatibility redirects. | +| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | +| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | +| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | +| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | +| DSM-5 Diagnosis | `/dsm` | `/dsm/search?q=major+depressive+disorder&focus=1&run=1` | `/dsm/diagnoses/[slug]`, `/dsm/compare`, and `/dsm/diagnoses/[slug]/differentials`. | +| Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | +| Formulation | `/formulation` | `/formulation?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | +| Medication | `/medications` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | +| Tools | `/tools` | `/tools?q=medications&focus=1&run=1` | Canonical Tools launcher at `/tools` (PT-11). `/?mode=tools` remains a dashboard-mode alias. | ## Documents flow index @@ -1100,7 +1102,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/applications` - Redirects to `/tools`. Source: `src/app/applications/route.ts`. - `/differentials/presentations` - Redirects to `/differentials/presentations/[workflow-slug]`. Source: `src/app/(search-app)/differentials/presentations/route.ts`. - `/documents/source` - Redirects to `/documents/[id]`. Source: `src/app/(search-app)/documents/source/page.tsx`. -- `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/(search-app)/medications/route.ts`. +- `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/(search-app)/medications/page.tsx`. - `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. - `/mockups/medication-prescribing` - Redirects to `/medications/acamprosate`. Source: `src/app/mockups/medication-prescribing/page.tsx`. diff --git a/src/app/(search-app)/documents/documents-home-client.tsx b/src/app/(search-app)/documents/documents-home-client.tsx new file mode 100644 index 0000000000..64ff043ec0 --- /dev/null +++ b/src/app/(search-app)/documents/documents-home-client.tsx @@ -0,0 +1,8 @@ +"use client"; + +import type { ReactNode } from "react"; + +/** Documents home content slot; search chrome and body come from the shared `(search-app)` shell. */ +export function DocumentsHomeClient({ children }: { children?: ReactNode }) { + return <>{children ?? null}; +} diff --git a/src/app/(search-app)/documents/page.tsx b/src/app/(search-app)/documents/page.tsx new file mode 100644 index 0000000000..f857224898 --- /dev/null +++ b/src/app/(search-app)/documents/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { DocumentsHomeClient } from "./documents-home-client"; + +export const metadata: Metadata = { + title: "Documents - Clinical KB", + description: "Browse indexed clinical sources, recent documents, and source PDFs.", +}; + +/** + * The Documents mode home. + * + * `/` is the single shared home for every mode — the mode pill retargets the + * composer rather than navigating — so Documents needs a home of its own, the same + * way /dsm, /services and /tools do. The body comes from ClinicalDashboard, which + * the shared shell mounts for this pathname (see `shouldRenderClinicalDashboard`); + * this route is the content slot, mirroring the root `home-page-client.tsx`. + */ +export default function DocumentsHomeRoute() { + return ; +} diff --git a/src/app/(search-app)/medications/medications-home-client.tsx b/src/app/(search-app)/medications/medications-home-client.tsx new file mode 100644 index 0000000000..e13fbd460f --- /dev/null +++ b/src/app/(search-app)/medications/medications-home-client.tsx @@ -0,0 +1,8 @@ +"use client"; + +import type { ReactNode } from "react"; + +/** Medication home content slot; search chrome and body come from the shared `(search-app)` shell. */ +export function MedicationsHomeClient({ children }: { children?: ReactNode }) { + return <>{children ?? null}; +} diff --git a/src/app/(search-app)/medications/page.tsx b/src/app/(search-app)/medications/page.tsx new file mode 100644 index 0000000000..1d6be4daf5 --- /dev/null +++ b/src/app/(search-app)/medications/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { MedicationsHomeClient } from "./medications-home-client"; + +export const metadata: Metadata = { + title: "Medication - Clinical KB", + description: "Medication dosing, safety, and monitoring guidance from indexed sources.", +}; + +/** + * The Medication mode home. + * + * Replaces the former 307 alias to `/?mode=prescribing`: `/` is now the single + * shared home for every mode, so prescribing needs its own home like every other + * mode. A submitted search still resolves to `/?mode=prescribing&q=…&run=1`, which + * stays dashboard-owned. The body comes from ClinicalDashboard, which the shared + * shell mounts for this pathname; this route is the content slot. + */ +export default function MedicationsHomeRoute() { + return ; +} diff --git a/src/app/(search-app)/medications/route.ts b/src/app/(search-app)/medications/route.ts deleted file mode 100644 index e9284566aa..0000000000 --- a/src/app/(search-app)/medications/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type NextRequest } from "next/server"; - -// Legacy prescribing home. Preserve the search context the same way the root -// legacy-mode redirect does (q plus the focus/run flags, sanitized) so a -// bookmarked /medications?q=lithium link keeps its query after the redirect. -export function GET(request: NextRequest) { - const params = new URLSearchParams({ mode: "prescribing" }); - const query = request.nextUrl.searchParams.get("q")?.trim(); - if (query) params.set("q", query); - if (request.nextUrl.searchParams.get("focus") === "1") params.set("focus", "1"); - if (request.nextUrl.searchParams.get("run") === "1") params.set("run", "1"); - return new Response(null, { - status: 307, - headers: { Location: `/?${params.toString()}` }, - }); -} - -export const HEAD = GET; diff --git a/src/app/(search-app)/page.tsx b/src/app/(search-app)/page.tsx index 21a73d4089..ce6951ceb8 100644 --- a/src/app/(search-app)/page.tsx +++ b/src/app/(search-app)/page.tsx @@ -3,6 +3,7 @@ import { connection } from "next/server"; import { HomePageClient } from "./home-page-client"; import { appModeHomeHref, isAppModeId, isAppModeVisible, type AppModeId } from "@/lib/app-modes"; +import { isDashboardModeHref } from "@/lib/search-route-ownership"; type HomeProps = { searchParams?: Promise>; @@ -23,69 +24,24 @@ export default async function Home({ searchParams }: HomeProps) { const initialSearchMode: AppModeId = isAppModeId(requestedMode) && isAppModeVisible(requestedMode) ? requestedMode : "answer"; - // /favourites is the canonical favourites surface; deep links via the - // dashboard mode param would otherwise open a divergent hub view. - if (initialSearchMode === "favourites") { - const favouriteParams = new URLSearchParams(); - const query = firstSearchParam(params.q)?.trim(); - if (query) favouriteParams.set("q", query); - if (firstSearchParam(params.focus) === "1") favouriteParams.set("focus", "1"); - if (firstSearchParam(params.run) === "1") favouriteParams.set("run", "1"); - const suffix = favouriteParams.toString(); - redirect(suffix ? `/favourites?${suffix}` : "/favourites"); - } - - if (initialSearchMode === "differentials") { - const differentialParams = new URLSearchParams(); - const query = firstSearchParam(params.q)?.trim(); - if (query) differentialParams.set("q", query); - if (firstSearchParam(params.focus) === "1") differentialParams.set("focus", "1"); - if (firstSearchParam(params.run) === "1") differentialParams.set("run", "1"); - const suffix = differentialParams.toString(); - redirect(suffix ? `/differentials?${suffix}` : "/differentials"); - } - - if (initialSearchMode === "dsm") { - const query = firstSearchParam(params.q)?.trim(); - redirect( - appModeHomeHref("dsm", { - query, - focus: firstSearchParam(params.focus) === "1", - run: firstSearchParam(params.run) === "1", - }), - ); - } - - if (initialSearchMode === "specifiers") { - const specifierParams = new URLSearchParams(); - const query = firstSearchParam(params.q)?.trim(); - if (query) specifierParams.set("q", query); - if (firstSearchParam(params.focus) === "1") specifierParams.set("focus", "1"); - if (firstSearchParam(params.run) === "1") specifierParams.set("run", "1"); - const suffix = specifierParams.toString(); - redirect(suffix ? `/specifiers?${suffix}` : "/specifiers"); - } - - if (initialSearchMode === "formulation") { - const formulationParams = new URLSearchParams(); - const query = firstSearchParam(params.q)?.trim(); - if (query) formulationParams.set("q", query); - if (firstSearchParam(params.focus) === "1") formulationParams.set("focus", "1"); - if (firstSearchParam(params.run) === "1") formulationParams.set("run", "1"); - const suffix = formulationParams.toString(); - redirect(suffix ? `/formulation?${suffix}` : "/formulation"); - } - - // Services/forms are namespaced mode homes; keep /?mode=* from mounting the - // full ClinicalDashboard + registry path for those surfaces. - if (initialSearchMode === "services" || initialSearchMode === "forms") { - redirect( - appModeHomeHref(initialSearchMode, { - query: firstSearchParam(params.q)?.trim(), - focus: firstSearchParam(params.focus) === "1", - run: firstSearchParam(params.run) === "1", - }), - ); + // `/` is the single home page for every mode: the mode pill retargets the + // composer rather than navigating, so a bare `/?mode=` must RENDER home + // with that mode preselected. Only a *submitted* deep link (a query plus + // `run=1`) still resolves to the mode's own search surface — that is the half + // of the old redirect behaviour worth keeping, since such a URL names results + // rather than a starting point. + const query = firstSearchParam(params.q)?.trim(); + const focus = firstSearchParam(params.focus) === "1"; + const run = firstSearchParam(params.run) === "1"; + const submitted = Boolean(query) && run; + + if (submitted) { + const destination = appModeHomeHref(initialSearchMode, { query, focus, run }); + // Answer and prescribing resolve back to `/?mode=…`; redirecting there would + // loop. Everything else names a route the dashboard does not own. + if (!isDashboardModeHref(destination)) { + redirect(destination); + } } return ; diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 12b52c29ca..770dc8d986 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,6 +1,6 @@ "use client"; import { useSettingsState } from "./clinical-dashboard/SettingsStateProvider"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CircleAlert, BookOpen, @@ -162,12 +162,14 @@ import { appModeResultKind, appModeCanUseSourceLibraryShortcut, appModeSearchConfig, + appModeSelectionHref, appModeSourceLibrarySearchMode, isAppModeId, isAppModeVisible, type AppModeId, type AppModeSearchKind, } from "@/lib/app-modes"; +import { useLastAppMode } from "@/components/clinical-dashboard/use-last-app-mode"; import { isDashboardModeHref } from "@/lib/search-route-ownership"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { @@ -327,6 +329,8 @@ export function ClinicalDashboard({ }: { initialSearchMode?: AppModeId; initialQuery?: string; focusSearch?: boolean; autoRunSearch?: boolean } = {}) { const router = useRouter(); const searchParams = useSearchParams(); + const pathname = usePathname(); + const [lastAppMode, setLastAppMode] = useLastAppMode(); const [initialSearchNavigationContext] = useState(() => readSearchNavigationContext(searchParams)); const scrollFrameRef = useRef(null); const navSyncLockRef = useRef(null); @@ -1592,6 +1596,18 @@ export function ClinicalDashboard({ return () => window.cancelAnimationFrame(frame); }, [searchParams, clearDifferentialModeResultState, focusComposerInput]); + // Seed a cold `/` visit from the remembered mode. `?mode=` always wins — it is + // the SSR source of truth, so a reloaded or shared link server-renders the right + // placeholder with no hydration flip. This only fills the gap when the URL says + // nothing, and does it with replaceState: no history entry, no server round trip, + // and only the placeholder changes (never composer geometry). + useEffect(() => { + if (pathname !== "/") return; + if (searchParams.has("mode") || searchParams.has("q") || searchParams.has("query")) return; + if (lastAppMode === searchMode) return; + window.history.replaceState(null, "", appModeSelectionHref(lastAppMode)); + }, [pathname, searchParams, lastAppMode, searchMode]); + useEffect(() => { if (urlSearchBootstrappedRef.current) return; const params = new URLSearchParams(window.location.search); @@ -2234,13 +2250,31 @@ export function ClinicalDashboard({ (selectedDocumentIds.length > 0 && auth.session?.user.id ? (persistPrivateSearchScope(window.sessionStorage, auth.session.user.id, selectedDocumentIds) ?? undefined) : undefined); + const navigationContext = { + queryMode: effectiveQueryMode, + scopeFilters: effectiveScopeFilters, + scopeRef: privateScopeRef, + }; + + // Submitting from the shared home routes to the selected mode's own search + // page. Only answer/prescribing (`/?mode=…`) and documents (`/documents/search`) + // are dashboard-owned and stay here; every namespaced mode navigates out. + // Without this, modes like DSM would silently run an in-dashboard search, + // because `/` + a namespaced mode was unreachable before the pill stopped + // navigating. + const modeDestination = appModeHomeHref(searchMode, { + query: trimmedQuery, + run: true, + ...navigationContext, + }); + if (trimmedQuery && !isDashboardModeHref(modeDestination)) { + rememberRecentQuery(trimmedQuery); + router.push(modeDestination); + return; + } + if (searchMode === "documents" && trimmedQuery) { rememberRecentQuery(trimmedQuery); - const navigationContext = { - queryMode: effectiveQueryMode, - scopeFilters: effectiveScopeFilters, - scopeRef: privateScopeRef, - }; autoRunSearchSignatureRef.current = searchSubmissionSignature(searchMode, trimmedQuery, navigationContext); window.history.pushState( null, @@ -2654,15 +2688,34 @@ export function ClinicalDashboard({ openAccountSetup("favourites"); return; } - const href = appModeHomeHref(mode, { queryMode, scopeFilters }); - // Leaving the dashboard shell (e.g. Answer → Services): navigate without - // rewriting local chrome first. Eager setSearchMode flipped overlay/hero - // and reserved dock padding for a frame before ClinicalDashboard unmounted. - if (!isDashboardModeHref(href)) { - modeChangeFromUiRef.current = true; - router.push(href); + setLastAppMode(mode); + + // On the shared home the pill is NOT navigation — it only decides where the + // composer will send you. Keep the page, the draft query and the scroll + // position, and rewrite `?mode=` in place. replaceState (not push) means Back + // still leaves home rather than stepping back through mode picks, and Next's + // router syncs `useSearchParams()` from it so the render-time URL sync keeps + // owning searchMode. + if (showSharedHome) { + // Deliberately NOT setting modeChangeFromUiRef: the URL sync effect must + // pick this up and own `searchMode`. It leaves the draft query alone + // (no `q` in the href) so switching mode mid-typing keeps what you wrote. + window.history.replaceState(null, "", appModeSelectionHref(mode, { queryMode, scopeFilters })); return; } + + // Results are on screen: carry the query into the newly picked mode rather + // than dropping it. crossModeSearch already owns that transition. + const carriedQuery = query.trim() || submittedUrlQuery.trim(); + if (carriedQuery) { + crossModeSearch(mode, carriedQuery); + return; + } + + // Nothing to carry: return to the shared home with the mode preselected. This + // always stays on `/`, so the transition is dashboard-internal — no unmount, + // and no chrome flip from an eager mode set before a route landed. + const href = appModeSelectionHref(mode, { queryMode, scopeFilters }); modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setQuery(""); @@ -3033,7 +3086,20 @@ export function ClinicalDashboard({ const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch); const submittedAnswerSearchActive = activeModeResultKind === "answer" && !answer && canRunSearch && (modeSearchSubmitted || Boolean(submittedUrlQuery)); - const showAnswerHome = activeModeResultKind === "answer" && !answer && !loading && !submittedAnswerSearchActive; + // `/` is the single home page for every mode. The mode pill retargets the + // composer instead of navigating, so the hero must not be answer-only: picking + // DSM on home keeps this exact surface and only swaps the placeholder. Gated on + // the pathname (never on `searchMode`) per the hero-vs-dock rule in + // docs/search-chrome-behaviour.md — a mode pick must not flip composer reserve. + const isHomeRoute = pathname === "/"; + const showSharedHome = + isHomeRoute && + !error && + !answer && + !loading && + !modeSearchSubmitted && + !submittedUrlQuery && + !submittedAnswerSearchActive; const showAnswerPending = activeModeResultKind === "answer" && !answer && (loading || (submittedAnswerSearchActive && !error)); const answerProgressCompleted = answerProgressEvents.at(-1)?.stage === "complete"; @@ -3054,14 +3120,14 @@ export function ClinicalDashboard({ (activeModeResultKind === "answer" && Boolean(answer) && !loading)); const showDesktopHomeComposer = !error && - (activeModeResultKind === "tools" || + (showSharedHome || + activeModeResultKind === "tools" || (activeModeResultKind === "favourites" && favouritesAccessible) || (!loading && - (showAnswerHome || - (searchMode === "documents" && - activeModeResultKind === "documents" && - documentMatches.length === 0 && - !modeSearchSubmitted) || + ((searchMode === "documents" && + activeModeResultKind === "documents" && + documentMatches.length === 0 && + !modeSearchSubmitted) || // Prescribing keeps MedicationHome (and the hero/phone composer) until // an explicit submit — draft keystrokes must not flip to results/dock. (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || @@ -3530,7 +3596,7 @@ export function ClinicalDashboard({ activeModeResultKind === "answer" && answer ? "sm:min-h-[calc(100dvh-11rem)]" : "min-h-[calc(100dvh-12.5rem)] sm:min-h-[calc(100dvh-11rem)]", - centeredModeHome || showAnswerHome + centeredModeHome || showSharedHome ? // Phones centre the home block mid-screen, matching the // standalone-route homes; the pop-up action surface picks // its own up/down placement so it stays unclipped either way. @@ -3652,7 +3718,20 @@ export function ClinicalDashboard({ ) : null} - {activeModeResultKind === "differentials" ? ( + {showSharedHome ? ( + // The one home surface, shared by all 13 modes. It sits above every + // mode-specific branch so picking a mode on `/` changes only the pill + // and the composer placeholder; the mode's own content stays behind + // its own route (/tools, /favourites, /dsm, …). + { + setQuery(recentQuery); + void ask(recentQuery); + }} + /> + ) : activeModeResultKind === "differentials" ? ( ) : null - ) : showAnswerHome ? ( - { - setQuery(recentQuery); - void ask(recentQuery); - }} - /> ) : null} {showUniversalAlsoMatches && activeModeResultKind === "answer" ? ( diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 596b755bb2..72a580153d 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -27,7 +27,7 @@ import { } from "@/components/ui-primitives"; import { Sheet } from "@/components/ui/sheet"; -import { appModeDefinition, type AppModeId } from "@/lib/app-modes"; +import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; export type SidebarIdentity = { displayName: string; @@ -56,9 +56,10 @@ function accountProfileLabel(identity: SidebarIdentity) { const sidebarToolItems = [ { id: "answer", label: "Answer", icon: Sparkles, href: "/?mode=answer" }, - { id: "documents", label: "Documents", icon: FileText, href: "/?mode=documents" }, + // Documents and Medication now have real homes, like every other mode. + { id: "documents", label: "Documents", icon: FileText, href: "/documents" }, { id: "services", label: "Services", icon: appModeIcons.services, href: "/services" }, - { id: "prescribing", label: appModeDefinition("prescribing").label, icon: Pill, href: "/?mode=prescribing" }, + { id: "prescribing", label: appModeDefinition("prescribing").label, icon: Pill, href: "/medications" }, { id: "factsheets", label: "Factsheets", icon: appModeIcons.factsheets, href: "/factsheets" }, // PT-11: standalone /tools is the canonical entry; /?mode=tools remains a dashboard-mode alias. { id: "tools", label: "Tools", icon: Wrench, href: "/tools" }, @@ -68,6 +69,29 @@ const sidebarAccountLibraryItems = [ { id: "favourites" as const, label: "Favourites", icon: Heart, href: "/favourites" }, ] as const; +/** + * The modes with no primary sidebar entry. The mode pill no longer navigates to a + * mode home (it retargets the shared composer), so without these the homes at + * /forms, /differentials, /dsm, /specifiers, /formulation and /therapy-compass + * would have no direct way in. Grouped below the primary list so the sidebar stays + * scannable. Labels and icons come from the registry so a rename lands in one place. + */ +const sidebarMoreModeIds = [ + "forms", + "differentials", + "dsm", + "specifiers", + "formulation", + "therapy-compass", +] as const satisfies readonly AppModeId[]; + +const sidebarMoreModeItems = sidebarMoreModeIds.map((id) => ({ + id, + label: appModeDefinition(id).label, + icon: appModeIcons[id], + href: appModeHomeHref(id), +})); + const visibleSidebarToolItems = sidebarToolItems; // Display-free base so callers can compose `grid` / `hidden lg:grid` without @@ -246,6 +270,40 @@ export function ClinicalSidebarContent({ +
+
+

More modes

+
+ +
+ {showAccountLibrary ? (
@@ -431,6 +489,25 @@ function ClinicalCollapsedRail({ ); })} +