From 93ea437610c1f1b681c3a5cbdc72fe8b9b178710 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 06:15:30 +0000 Subject: [PATCH 1/4] feat(differentials): finish four-page Search/Diagnoses/Presentations/Compare nav Make Presentations and Compare equal-level destinations, restore Search with q+run=1, show kind labels on unified results, and land Compare on a queue page before launching the presentation or ad-hoc workspace. Co-authored-by: BigSimmo --- docs/site-map.md | 2 +- scripts/generate-site-map.ts | 2 +- .../differentials/compare/page.tsx | 34 +++- .../clinical-dashboard/differentials-home.tsx | 18 ++ .../differential-compare-queue-page.tsx | 154 ++++++++++++++++++ src/lib/differentials.ts | 24 +++ src/lib/mode-secondary-navigation.ts | 13 +- tests/differentials-navigation.test.ts | 36 +++- tests/mode-secondary-navigation.test.ts | 15 +- 9 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 src/components/differentials/differential-compare-queue-page.tsx diff --git a/docs/site-map.md b/docs/site-map.md index 94bec969a8..3e68cc8cdd 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -7,7 +7,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/` - Main Clinical KB shell. Source: `src/app/(search-app)/page.tsx`. - `/calculators` - Route discovered from app directory Source: `src/app/(search-app)/calculators/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/(search-app)/differentials/page.tsx`. -- `/differentials/compare` - Compare entry: same-presentation selections redirect into a catalogue workflow; cross-presentation selections render an ad-hoc comparison. Source: `src/app/(search-app)/differentials/compare/page.tsx`. +- `/differentials/compare` - Compare queue: empty state or selected diagnosis ids; Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`). Source: `src/app/(search-app)/differentials/compare/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/(search-app)/differentials/diagnoses/page.tsx`. - `/differentials/presentations` - Presentation catalogue stream. Source: `src/app/(search-app)/differentials/presentations/page.tsx`. - `/differentials/presentations/[slug]` - Presentation comparison workflow. Source: `src/app/(search-app)/differentials/presentations/[slug]/page.tsx`. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index ad3f01f19f..bc0cd1bb66 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -54,7 +54,7 @@ const routeDescriptions: Record = { "/applications": "Legacy application launcher redirect to Tools.", "/differentials": "Differentials home and search surface.", "/differentials/compare": - "Compare entry: same-presentation selections redirect into a catalogue workflow; cross-presentation selections render an ad-hoc comparison.", + "Compare queue: empty state or selected diagnosis ids; Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`).", "/differentials/diagnoses": "Diagnosis stream.", "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", "/differentials/presentations": "Presentation catalogue stream.", diff --git a/src/app/(search-app)/differentials/compare/page.tsx b/src/app/(search-app)/differentials/compare/page.tsx index e30783cc33..a3e7e48c4a 100644 --- a/src/app/(search-app)/differentials/compare/page.tsx +++ b/src/app/(search-app)/differentials/compare/page.tsx @@ -1,8 +1,13 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { DifferentialCompareQueuePage } from "@/components/differentials/differential-compare-queue-page"; import { DifferentialPresentationWorkflowPage } from "@/components/differentials/differential-presentation-workflow-page"; -import { resolveDifferentialCompareHandoff } from "@/lib/differentials"; +import { + differentialCompareQueueItems, + resolveDifferentialCompareHandoff, + resolveDifferentialCompareLaunchHref, +} from "@/lib/differentials"; export const metadata: Metadata = { title: "Compare differentials | Clinical KB", @@ -10,7 +15,12 @@ export const metadata: Metadata = { }; type DifferentialCompareRouteProps = { - searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; ids?: string | string[] }>; + searchParams?: Promise<{ + query?: string | string[]; + q?: string | string[]; + ids?: string | string[]; + workspace?: string | string[]; + }>; }; function firstSearchParam(value?: string | string[]) { @@ -18,12 +28,9 @@ function firstSearchParam(value?: string | string[]) { } /** - * Compare entry page. - * - * Same-presentation selections (and bare/unknown ids) redirect into a catalogue - * presentation workflow. Cross-presentation selections render an ad-hoc compare - * view here so every valid id is preserved. A competing `route.ts` at this path - * is invalid in the App Router — handoff lives in the page instead. + * Compare queue: empty state or selected diagnosis ids. Open comparison launches + * a catalogue presentation workflow, or an ad-hoc workspace (`workspace=1`) when + * selections span presentations. */ export default async function DifferentialCompareRoute({ searchParams }: DifferentialCompareRouteProps) { const resolvedSearchParams = searchParams ? await searchParams : {}; @@ -32,6 +39,17 @@ export default async function DifferentialCompareRoute({ searchParams }: Differe .split(",") .map((value) => value.trim()) .filter(Boolean); + const workspace = firstSearchParam(resolvedSearchParams.workspace)?.trim() === "1"; + + if (!workspace) { + return ( + + ); + } const handoff = resolveDifferentialCompareHandoff(selectedIds, query); if (handoff.kind === "presentation") { diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 1bf2753357..0f07fb8b07 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -428,6 +428,12 @@ function DesktopResultRow({ > {result.title} + + {result.kind === "presentation" ? "Presentation" : "Diagnosis"} +

@@ -496,6 +502,12 @@ function MobileResultCard({ {result.title}

+ + {result.kind === "presentation" ? "Presentation" : "Diagnosis"} +
@@ -576,6 +588,12 @@ function BestAnswerCard({
+ + {best.kind === "presentation" ? "Presentation" : "Diagnosis"} + item.slug).filter((id) => id !== slug); + router.replace(differentialRouteWithQuery("/differentials/compare", trimmedQuery, nextIds)); + } + + if (items.length === 0) { + return ( +
+
+
+

Compare

+

+ Tick diagnoses on Search to build a comparison +

+

+ The compare queue is empty. Search differentials, select diagnoses, then return here to open a + side-by-side presentation workflow. +

+
+ + + {trimmedQuery ? "Back to Search results" : "Open Search"} + + + Browse diagnoses + +
+
+
+
+ ); + } + + return ( +
+
+
+
+
+

+ Compare queue +

+

+ {items.length} diagnosis{items.length === 1 ? "" : "es"} selected +

+

+ Review the queue, remove any diagnosis you do not need, then open the comparison workspace. +

+ {trimmedQuery ? ( +

Query: {trimmedQuery}

+ ) : null} +
+ + + Search + +
+
+ +
+ {items.map((item) => ( +
+ + {item.title} + + +
+ ))} +
+ +
+ + + Open comparison + + + Edit selection on Search + +
+
+
+ ); +} diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts index a35592295f..aedb4569b0 100644 --- a/src/lib/differentials.ts +++ b/src/lib/differentials.ts @@ -315,6 +315,30 @@ export function resolveDifferentialCompareHandoff(ids: Iterable, query = }; } +/** + * Href for the Compare queue "Open comparison" CTA. + * Catalogue-hosted selections open the presentation workflow; cross-presentation + * selections open the ad-hoc workspace on `/differentials/compare`. + */ +export function resolveDifferentialCompareLaunchHref(ids: Iterable, query = ""): string { + const handoff = resolveDifferentialCompareHandoff(ids, query); + if (handoff.kind === "presentation") return handoff.href; + const params = new URLSearchParams(); + const trimmedQuery = query.trim(); + if (trimmedQuery) params.set("q", trimmedQuery); + if (handoff.selection.diagnosisIds.length) params.set("ids", handoff.selection.diagnosisIds.join(",")); + params.set("workspace", "1"); + return `/differentials/compare?${params.toString()}`; +} + +/** Queue rows for the Compare page (title lookup from the local catalogue). */ +export function differentialCompareQueueItems(ids: Iterable): Array<{ slug: string; title: string }> { + return normalizeRequestedDiagnosisIds(ids).map((slug) => ({ + slug, + title: getDifferentialRecord(slug)?.title ?? slug.replace(/-/g, " "), + })); +} + export const acuteConfusionPresentationWorkflow: DifferentialPresentationWorkflow = getPresentationWorkflow("acute-confusion-encephalopathy") ?? differentialPresentations()[0]!; diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts index e9216a74ea..cf1e04a1eb 100644 --- a/src/lib/mode-secondary-navigation.ts +++ b/src/lib/mode-secondary-navigation.ts @@ -142,11 +142,9 @@ export function routedModeSecondaryNavigationCount(modeId: AppModeId): number { export function activeModeSecondaryNavigationId(modeId: AppModeId, pathname: string): string | null { if (modeId === "differentials") { if (pathname.startsWith("/differentials/diagnoses")) return "diagnoses"; - // Catalogue owns the exact presentations path; workflow slugs are the Compare surface. - if (pathname === "/differentials/presentations" || pathname.startsWith("/differentials/presentations?")) { - return "presentations"; - } - if (pathname.startsWith("/differentials/presentations/") || pathname.startsWith("/differentials/compare")) { + // Browse + presentation detail are one Presentations family (symmetric with Diagnoses). + if (pathname.startsWith("/differentials/presentations")) return "presentations"; + if (pathname === "/differentials/compare" || pathname.startsWith("/differentials/compare/")) { return "compare"; } if (pathname === "/differentials" || pathname.startsWith("/differentials?")) return "search"; @@ -252,7 +250,10 @@ export function modeSecondaryNavigationHref(params: { if (modeId === "differentials") { const entries: Array = query ? [["q", query]] : []; - if (itemId === "search" && currentSearchParams.get("run") === "1") entries.push(["run", "1"]); + // Returning to Search with a carried query must reopen the results view + // (`run=1`), not the empty mode home — even when the previous tab lacked run. + if (itemId === "search" && query) entries.push(["run", "1"]); + else if (itemId === "search" && currentSearchParams.get("run") === "1") entries.push(["run", "1"]); // Compare (and other in-mode tabs) reuse URL-backed selection so ticks on // search survive ModeNav handoff without a second client store. if (currentSearchParams.get("ids")) { diff --git a/tests/differentials-navigation.test.ts b/tests/differentials-navigation.test.ts index e5ab789462..577eb79927 100644 --- a/tests/differentials-navigation.test.ts +++ b/tests/differentials-navigation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveDifferentialCompareHandoff } from "@/lib/differentials"; +import { + differentialCompareQueueItems, + resolveDifferentialCompareHandoff, + resolveDifferentialCompareLaunchHref, +} from "@/lib/differentials"; import { differentialIdsFromSearchParams, differentialRouteWithQuery, @@ -79,4 +83,34 @@ describe("differentials navigation", () => { expect(handoff.href).toContain("anorexia-nervosa"); expect(handoff.href).toContain("bulimia-nervosa-binge-purge-pattern"); }); + + it("launches same-presentation compare into the presentation workflow", () => { + const href = resolveDifferentialCompareLaunchHref( + ["anorexia-nervosa", "bulimia-nervosa-binge-purge-pattern"], + "Pain", + ); + expect(href).toMatch(/^\/differentials\/presentations\/[^/?]+/); + expect(href).toContain("ids="); + }); + + it("launches cross-presentation compare into the compare workspace", () => { + const href = resolveDifferentialCompareLaunchHref( + ["medical-gi-endocrine-painful-organic-cause", "bpsd-as-unmet-need-delirium-pain-mimic"], + "Pain", + ); + expect(href).toContain("/differentials/compare?"); + expect(href).toContain("workspace=1"); + expect(href).toContain("ids="); + }); + + it("builds compare queue titles from known diagnosis slugs only", () => { + const items = differentialCompareQueueItems([ + "delirium", + "unknown-diagnosis-slug", + "dementia-neurocognitive-disorder", + ]); + expect(items.map((item) => item.slug)).toEqual(["delirium", "dementia-neurocognitive-disorder"]); + expect(items[0]).toEqual({ slug: "delirium", title: "Delirium" }); + expect(items[1]?.title.toLowerCase()).toContain("dementia"); + }); }); diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index 074b5308af..924b5882d0 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -181,6 +181,17 @@ describe("mode secondary navigation registry", () => { }), ).toBe("/differentials/presentations?q=confusion&ids=delirium%2Cdementia"); + // Search restores the last query and re-opens results even when the prior + // tab URL did not carry run=1 (e.g. Diagnoses / Presentations browse). + expect( + modeSecondaryNavigationHref({ + modeId: "differentials", + itemId: "search", + href: "/differentials?focus=1", + currentSearchParams: new URLSearchParams("q=confusion&ids=delirium"), + }), + ).toBe("/differentials?focus=1&q=confusion&run=1&ids=delirium"); + // Search is the CURRENT tab on /factsheets/search, so its own link must not // reset what you are looking at. `run` is carried with the query because // dropping it flips hasSubmittedModeSearch and re-places the composer. @@ -278,7 +289,7 @@ describe("mode secondary navigation registry", () => { expect(activeModeSecondaryNavigationId("differentials", "/differentials/compare")).toBe("compare"); expect( activeModeSecondaryNavigationId("differentials", "/differentials/presentations/acute-confusion-encephalopathy"), - ).toBe("compare"); + ).toBe("presentations"); }); }); @@ -327,7 +338,7 @@ describe("differentials mode secondary navigation active destinations", () => { ); expect( activeModeSecondaryNavigationId("differentials", "/differentials/presentations/acute-confusion-encephalopathy"), - ).toBe("compare"); + ).toBe("presentations"); expect(activeModeSecondaryNavigationId("differentials", "/differentials/compare")).toBe("compare"); expect(activeModeSecondaryNavigationId("differentials", "/differentials/diagnoses")).toBe("diagnoses"); }); From bdb121d5336c04d7cd25ded7b2b09047b3494968 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 06:16:07 +0000 Subject: [PATCH 2/4] docs(ledger): record differentials four-page nav handoff Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index ab644a3265..c9eb51df62 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -830,3 +830,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | claude/document-viewer-optimization-tu8tnj | 5a0d6be02bc92fa2615d2141b338ec8f7c1143b1 | docs: document-viewer Phase 3 handover brief (PR #1765) | Supersedes the earlier row, whose 'all ten gates completed' wording could read as all executable checks having run. Correct scope: verify:pr-local ran the ten gates APPLICABLE to docs-only changes (check:runtime, check:installed-lock-parity, format:changed, sitemap:check, docs:check-index, docs:check-inventory, docs:check-scripts, docs:check-links, check:branch-review-ledger, check:outstanding-issues); the risk router SKIPPED lint, typecheck, the full unit suite, RAG fixture validation, and build as recognised low-risk documentation scope. Also records the merge resolution: duplicate #286 (main's in-page-nav series vs this branch's authorizationHeader row) resolved by renumbering the branch row to #289, next-id 290, after the auto-merge silently dropped that detail row rather than conflicting. Review findings addressed: governance preflight now required by behaviour per AGENTS.md:257 rather than inferred from pr-policy path classification; API-route scope contradiction resolved; signed-URL warning corrected to state both identity bugs are already fixed on main with regression coverage. | verify:pr-local ten docs-scope gates passed, none failed; check:outstanding-issues 287 rows unique ids next-id=290 no ids deleted; ledger:dedupe 771 unique rows; git merge-tree vs origin/main exit 0; viewer line refs re-verified against 50ef12e | | 2026-08-09 | claude/m2-ds-gates-blocking | d204c6f7c84a5e7de3f28061121fda68e7d28670 | M2 design-system gates: #264 + gate 4 of #265 | ready-to-merge; supersedes the 8ed66a05 row — the tap-floor defect renumbered #289 to #291 after main claimed #289/#290, and main was merged in | post-merge ds-contract PASS (colour-only 4, numerals 2, inversions 0) against main's new #1765/#1766 component code; outstanding-issues guard PASS 289 rows next-id=292; ledger guard PASS 774 rows; format clean | | 2026-08-09 | claude/m2-ds-gates-blocking | e8447b042088998d34af750df72a946b1f074b97 | M2 design-system gates: #264 + gate 4 of #265 | ready-to-merge; supersedes the d204c6f7 row — seven review findings fixed, copilot-swe-agent commits merged keeping the safer numeral classifier, tap-floor defect renumbered #291 to #293 after main claimed #291/#292 | ds-contract PASS (colour-only 4, numerals 2, inversions 0); 11 reviewer cases probe-verified; mutation-verified incl. opacity and arbitrary-filter forms; lint 0; tsc 0 errors; format clean; outstanding-issues guard PASS 291 rows next-id=294 no ids deleted; ledger guard PASS 775 rows | +| 2026-08-09 | cursor/differentials-four-page-nav-5ebf | 93ea437610c1f1b681c3a5cbdc72fe8b9b178710 | differentials four-page nav | implemented Search/Diagnoses/Presentations/Compare equal pages; compare queue; kind labels; Search q+run restore | vitest nav+differentials-navigation; typecheck; lint; full unit 5814 passed | From 384a1bedd8dd1064fb2fcf26ac845224e2cafdc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 08:37:03 +0000 Subject: [PATCH 3/4] fix(differentials): clear compare-queue review and CI blockers Preserve Search ids from the compare queue, show ModeNav route gates for presentation detail paths, convert the queue page to an RSC to stay within bundle budget, and update the Playwright compare journey for the queue CTA. Co-authored-by: BigSimmo --- .../differential-compare-queue-page.tsx | 79 ++++++++----------- src/lib/differentials-navigation.ts | 17 ++++ src/lib/mode-secondary-navigation.ts | 4 +- tests/differentials-navigation.test.ts | 9 +++ tests/mode-secondary-navigation.test.ts | 4 +- tests/ui-tools.spec.ts | 58 ++++++++------ 6 files changed, 99 insertions(+), 72 deletions(-) diff --git a/src/components/differentials/differential-compare-queue-page.tsx b/src/components/differentials/differential-compare-queue-page.tsx index a5f9cdfc95..87104264ac 100644 --- a/src/components/differentials/differential-compare-queue-page.tsx +++ b/src/components/differentials/differential-compare-queue-page.tsx @@ -1,11 +1,7 @@ -"use client"; - import Link from "next/link"; -import { useRouter } from "next/navigation"; import { ArrowLeft, GitCompareArrows, Search, X } from "lucide-react"; -import { appModeHomeHref } from "@/lib/app-modes"; -import { differentialRouteWithQuery } from "@/lib/differentials-navigation"; +import { differentialCompareSearchHref, differentialRouteWithQuery } from "@/lib/differentials-navigation"; export type DifferentialCompareQueueItem = { slug: string; @@ -18,26 +14,18 @@ type DifferentialCompareQueuePageProps = { openComparisonHref: string; }; -function searchHref(query: string) { - return appModeHomeHref("differentials", { - query: query.trim() || undefined, - run: Boolean(query.trim()), - focus: true, - }); -} - +/** + * Compare queue (empty or selected diagnosis ids). Server Component so the + * route does not add a client chunk against the repo-wide gzip budget. + */ export function DifferentialCompareQueuePage({ query = "", items, openComparisonHref, }: DifferentialCompareQueuePageProps) { - const router = useRouter(); const trimmedQuery = query.trim(); - - function removeId(slug: string) { - const nextIds = items.map((item) => item.slug).filter((id) => id !== slug); - router.replace(differentialRouteWithQuery("/differentials/compare", trimmedQuery, nextIds)); - } + const selectedIds = items.map((item) => item.slug); + const editSelectionHref = differentialCompareSearchHref(trimmedQuery, selectedIds); if (items.length === 0) { return ( @@ -46,7 +34,7 @@ export function DifferentialCompareQueuePage({ className="min-h-[calc(100dvh-var(--shell-header-h))] bg-[color:var(--background)] px-4 py-10 text-[color:var(--text)] sm:px-6 lg:px-8" >
-
+

Compare

Tick diagnoses on Search to build a comparison @@ -57,7 +45,7 @@ export function DifferentialCompareQueuePage({

@@ -82,7 +70,7 @@ export function DifferentialCompareQueuePage({ className="min-h-[calc(100dvh-var(--shell-header-h))] bg-[color:var(--background)] px-4 py-10 text-[color:var(--text)] sm:px-6 lg:px-8" >
-
+

@@ -99,7 +87,7 @@ export function DifferentialCompareQueuePage({ ) : null}

@@ -109,27 +97,29 @@ export function DifferentialCompareQueuePage({
- {items.map((item) => ( -
- { + const remainingIds = selectedIds.filter((id) => id !== item.slug); + return ( +
- {item.title} - - -
- ))} + + {item.title} + + + + +
+ ); + })}
@@ -142,7 +132,8 @@ export function DifferentialCompareQueuePage({ Open comparison Edit selection on Search diff --git a/src/lib/differentials-navigation.ts b/src/lib/differentials-navigation.ts index 115314b3c4..abebc64df3 100644 --- a/src/lib/differentials-navigation.ts +++ b/src/lib/differentials-navigation.ts @@ -4,6 +4,23 @@ * clinical-dashboard client bundle stays fixture-free. */ +import { appModeHomeHref } from "@/lib/app-modes"; + +/** Search home/results href that preserves compare-queue diagnosis ids. */ +export function differentialCompareSearchHref(query: string, selectedIds: Iterable = []) { + const trimmedQuery = query.trim(); + const base = appModeHomeHref("differentials", { + query: trimmedQuery || undefined, + run: Boolean(trimmedQuery), + focus: true, + }); + const ids = Array.from(selectedIds, (id) => id.trim()).filter(Boolean); + if (!ids.length) return base; + const url = new URL(base, "http://differential-compare.local"); + url.searchParams.set("ids", ids.join(",")); + return `${url.pathname}${url.search}${url.hash}`; +} + export function differentialRouteWithQuery( path: string, query: string, diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts index cf1e04a1eb..8d94a9ff72 100644 --- a/src/lib/mode-secondary-navigation.ts +++ b/src/lib/mode-secondary-navigation.ts @@ -197,7 +197,9 @@ export function isModeSecondaryNavigationRoute(params: { return ( pathname === "/differentials/diagnoses" || pathname === "/differentials/presentations" || - pathname === "/differentials/compare" + pathname.startsWith("/differentials/presentations/") || + pathname === "/differentials/compare" || + pathname.startsWith("/differentials/compare/") ); } if (modeId === "dsm") return pathname === "/dsm/search" || pathname === "/dsm/compare"; diff --git a/tests/differentials-navigation.test.ts b/tests/differentials-navigation.test.ts index 577eb79927..c935317e42 100644 --- a/tests/differentials-navigation.test.ts +++ b/tests/differentials-navigation.test.ts @@ -6,6 +6,7 @@ import { resolveDifferentialCompareLaunchHref, } from "@/lib/differentials"; import { + differentialCompareSearchHref, differentialIdsFromSearchParams, differentialRouteWithQuery, differentialSelectedCompareHref, @@ -113,4 +114,12 @@ describe("differentials navigation", () => { expect(items[0]).toEqual({ slug: "delirium", title: "Delirium" }); expect(items[1]?.title.toLowerCase()).toContain("dementia"); }); + + it("preserves compare-queue ids when returning to Search", () => { + const href = differentialCompareSearchHref("Pain", ["wernicke-encephalopathy", "delirium"]); + expect(href).toContain("/differentials?"); + expect(href).toContain("q=Pain"); + expect(href).toContain("run=1"); + expect(href).toMatch(/ids=wernicke-encephalopathy%2Cdelirium|ids=wernicke-encephalopathy,delirium/); + }); }); diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index 924b5882d0..597617e00a 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -343,7 +343,7 @@ describe("differentials mode secondary navigation active destinations", () => { expect(activeModeSecondaryNavigationId("differentials", "/differentials/diagnoses")).toBe("diagnoses"); }); - it("opens the mode bar on the presentations catalogue and compare entry", () => { + it("opens the mode bar on the presentations catalogue, presentation detail, and compare entry", () => { expect( isModeSecondaryNavigationRoute({ modeId: "differentials", @@ -364,6 +364,6 @@ describe("differentials mode secondary navigation active destinations", () => { pathname: "/differentials/presentations/acute-confusion-encephalopathy", hasSubmittedSearch: false, }), - ).toBe(false); + ).toBe(true); }); }); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 27a21bfe86..199cbdc911 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -2135,35 +2135,52 @@ test.describe("Clinical KB tools launcher", () => { expect(overviewLineCount).toBe(1); }); - test("differentials presentation comparison page stays wired to differentials mode", async ({ page }) => { + test("differentials compare queue launches presentation comparison", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 920 }); const workflow = acuteConfusionPresentationWorkflow; + await gotoLauncher(page, "/differentials/compare"); + await expect(page).toHaveURL(/\/differentials\/compare\/?$/, { timeout: 30_000 }); + await expect(page.getByTestId("differential-compare-empty")).toBeVisible(); + await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Compare", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("link", { name: "Open Search" })).toBeVisible(); + // Queue is a mode surface (composer allowed); presentation workflow below still owns no-composer chrome. + await expectNoPageHorizontalOverflow(page); + + await gotoLauncher(page, "/differentials/compare?ids=wernicke-encephalopathy&q=Pain"); + await expect(page).toHaveURL(/\/differentials\/compare/); + await expect(page).toHaveURL(/ids=wernicke-encephalopathy/); + const queue = page.getByTestId("differential-compare-queue"); + await expect(queue).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("heading", { level: 1, name: "1 diagnosis selected" })).toBeVisible(); + await expect(queue.getByRole("link", { name: "Wernicke encephalopathy", exact: true })).toBeVisible(); + await expect(page.getByTestId("differential-compare-edit-selection")).toHaveAttribute( + "href", + /\/differentials\?.*ids=wernicke-encephalopathy/, + ); + await expect(page.getByTestId("differential-compare-open")).toBeVisible(); + + await page.getByTestId("differential-compare-open").click(); await expect(page).toHaveURL(/\/differentials\/presentations\/acute-confusion-encephalopathy/, { timeout: 30_000 }); + await expect(page).toHaveURL(/ids=wernicke-encephalopathy/); - await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); await expect( page.getByTestId("mobile-composer-reserve-pad").getByTestId("differential-presentation-page"), ).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: workflow.title })).toBeVisible(); await expect( - page - .getByRole("heading", { - name: `Selected differentials (${workflow.selectedCount} of ${workflow.totalCount})`, - }) - .first(), + page.getByRole("heading", { name: `Selected differentials (1 of ${workflow.totalCount})` }).first(), + ).toBeVisible(); + await expect( + page.locator("span:visible", { hasText: `+${workflow.totalCount - 1} not selected` }).first(), ).toBeVisible(); - await expect(page.getByRole("link", { name: "Back" })).toHaveAttribute("href", "/differentials"); await expect(page.getByRole("heading", { name: "Safety snapshot" }).first()).toBeVisible(); await expect(page.getByText("Service details")).toHaveCount(0); await expect(page.getByText("Transport order")).toHaveCount(0); await expect(page.getByLabel("Differential review sidebar").getByText("Local content only").first()).toBeVisible(); - await expect( - page.getByLabel("Differential review sidebar").getByText("Source pending review").first(), - ).toBeVisible(); await expect(page.getByRole("button", { name: "Copy after review" })).toBeVisible(); await expect(page.getByRole("button", { name: "Edit columns" })).toBeDisabled(); - await expect(page.getByText("Long press to reorder. Tap to remove.")).toHaveCount(0); await expect(page.getByTestId("global-search-input")).toHaveCount(0); const tableScrolls = await page.getByTestId("differential-comparison-scroll").evaluate((element) => { @@ -2174,17 +2191,10 @@ test.describe("Clinical KB tools launcher", () => { expect(desktopTableBox?.width ?? 0).toBeGreaterThan(900); await expectNoPageHorizontalOverflow(page); - await gotoLauncher(page, "/differentials/compare?ids=wernicke-encephalopathy"); - await expect(page).toHaveURL(/ids=wernicke-encephalopathy/); - await expect( - page.getByRole("heading", { name: `Selected differentials (1 of ${workflow.totalCount})` }).first(), - ).toBeVisible(); - await expect( - page.locator("span:visible", { hasText: `+${workflow.totalCount - 1} not selected` }).first(), - ).toBeVisible(); - await page.setViewportSize({ width: 390, height: 844 }); - await gotoLauncher(page, "/differentials/compare"); + await gotoLauncher(page, "/differentials/compare?ids=wernicke-encephalopathy"); + await expect(page.getByTestId("differential-compare-queue")).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("differential-compare-open").click(); await expect(page).toHaveURL(/\/differentials\/presentations\/acute-confusion-encephalopathy/, { timeout: 30_000 }); // Scope to the live shell scrollport: Next may briefly retain a hidden @@ -2195,12 +2205,10 @@ test.describe("Clinical KB tools launcher", () => { .getByTestId("differential-presentation-page"); await expect(presentationPage).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("link", { name: "Back to differentials" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Compare", exact: true })).toHaveAttribute("aria-current", "page"); await expect(page.getByRole("heading", { level: 1, name: workflow.title })).toBeVisible(); const mobileComparison = page.getByLabel("Mobile differential comparison"); await expect(mobileComparison.getByRole("button", { name: "Column filters unavailable" })).toBeDisabled(); - await expect(mobileComparison.getByText("Delirium", { exact: true }).first()).toBeVisible(); - await expect(mobileComparison.getByText("Substance intoxication", { exact: true }).first()).toBeVisible(); + await expect(mobileComparison.getByText("Wernicke encephalopathy", { exact: true }).first()).toBeVisible(); const languageControl = page.getByRole("button", { name: "Language and region settings (coming soon)" }); await expect(languageControl).toBeVisible(); await expect(languageControl).toHaveAttribute("aria-disabled", "true"); From bcad7ab66ac9c41bc83b3396eef0ca174fd14378 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 08:37:04 +0000 Subject: [PATCH 4/4] docs: record PR #1774 babysit ledger and compare Search ids note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append the heavy review-and-fix ledger row for the fix tip and document that Compare→Search edit links preserve selected diagnosis ids. Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + docs/site-map.md | 2 +- scripts/generate-site-map.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 54646e3d58..62925761d6 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -840,3 +840,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | cursor/differentials-diagnosis-links-9f18 | f784e81bcc0b53ef76b3da07a8e81f96d9bf0c71 | pr-1768 unblock | merged origin/main onto ba590f9; merge-tree clean; DIRTY mergeability cleared; push tip follows amend with this ledger | merge-tree clean; threads resolved; auto-merge was armed | | 2026-08-09 | claude/planning-build-intelligence-9ot0nm | 3df3cb3993f73cda4dbbc4ac7549f84b3c6ea7ed | Node 24.15 engine floor: engines.node, preinstall hook, check:runtime, session-start provisioning, codex-cloud assertion | Authored and handed off as PR #1771; closes #285; operationalRisk true, clinicalRisk/ragRanking false | test 5800 passed/1 pre-existing root-uid failure (pr-handoff-stop, confirmed on stashed clean tree); lint 0; typecheck 0; prettier --check . pass; check:runtime pass; check:codex-cloud pass; check:outstanding-issues pass; preinstall boundary proof 24.13/24.14.9 reject, 24.15/24.19 accept, 25.0.0 reject; contract test mutation-checked red | | 2026-08-09 | pull/1771 | 466ec4216272c31c5f754db213dbdc529583b167 | PR 1771 runtime floor enforcement | P2: Cloud and Desktop setup paths remain major-only; do not merge until range-aware | static review; check:runtime PASS; check:codex-cloud PASS; ledger PASS; outstanding issues PASS; focused Vitest blocked by active Playwright lease | +| 2026-08-09 | cursor/differentials-four-page-nav-5ebf | 384a1bedd8dd1064fb2fcf26ac845224e2cafdc4 | PR #1774 differentials four-page nav heavy review-and-fix | fixed P1 ids+Playwright; ModeNav route gate; RSC queue clears bundle+shadow; Copilot ModeNav-on-detail dispositioned (info page); ledger reorder dispositioned (merge=ledger) | vitest nav 47p; design-system-contract; typecheck; lint; test 5897p; build+bundle-budget 1543.7 within tol; focused pw compare queue 1p | diff --git a/docs/site-map.md b/docs/site-map.md index 3e68cc8cdd..4a7df112bc 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -7,7 +7,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/` - Main Clinical KB shell. Source: `src/app/(search-app)/page.tsx`. - `/calculators` - Route discovered from app directory Source: `src/app/(search-app)/calculators/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/(search-app)/differentials/page.tsx`. -- `/differentials/compare` - Compare queue: empty state or selected diagnosis ids; Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`). Source: `src/app/(search-app)/differentials/compare/page.tsx`. +- `/differentials/compare` - Compare queue: empty state or selected diagnosis ids (Search edit links preserve ids); Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`). Source: `src/app/(search-app)/differentials/compare/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/(search-app)/differentials/diagnoses/page.tsx`. - `/differentials/presentations` - Presentation catalogue stream. Source: `src/app/(search-app)/differentials/presentations/page.tsx`. - `/differentials/presentations/[slug]` - Presentation comparison workflow. Source: `src/app/(search-app)/differentials/presentations/[slug]/page.tsx`. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index bc0cd1bb66..3ed43b080e 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -54,7 +54,7 @@ const routeDescriptions: Record = { "/applications": "Legacy application launcher redirect to Tools.", "/differentials": "Differentials home and search surface.", "/differentials/compare": - "Compare queue: empty state or selected diagnosis ids; Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`).", + "Compare queue: empty state or selected diagnosis ids (Search edit links preserve ids); Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`).", "/differentials/diagnoses": "Diagnosis stream.", "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", "/differentials/presentations": "Presentation catalogue stream.",