Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
901c336
test(mode-nav): define multi-page-only route eligibility
BigSimmo Aug 6, 2026
4dddce2
test(mode-nav): require shared navigation for every multi-page workflow
BigSimmo Aug 6, 2026
2e91af9
fix: apply Prettier formatting to failing test files
Copilot Aug 6, 2026
e135e45
feat(mode-nav): add registry-backed header navigation adapter
BigSimmo Aug 6, 2026
d730792
feat(mode-nav): suppress single-page menus and include multi-page homes
BigSimmo Aug 6, 2026
a8b4e10
feat(mode-nav): render route-level menus through the shared header nav
BigSimmo Aug 6, 2026
7fdda1c
feat(mode-nav): move Formulation workflow navigation into the header
BigSimmo Aug 6, 2026
50a1bdb
feat(mode-nav): move Specifiers workflow navigation into the header
BigSimmo Aug 6, 2026
c956fc3
test(mode-nav): align clean-home coverage with shared navigation
BigSimmo Aug 6, 2026
075f0f5
test(mode-nav): cover clean homes and format shared navigation tests
BigSimmo Aug 6, 2026
fe56d98
docs(mode-nav): update shared header slot ownership contract
BigSimmo Aug 6, 2026
43a8318
test(mode-nav): exercise real workflow owners and forward Link refs
BigSimmo Aug 6, 2026
525be31
Merge branch 'main' into codex/standardise-multi-page-mode-nav
BigSimmo Aug 6, 2026
2a71d46
style(mode-nav): apply repository formatting
BigSimmo Aug 6, 2026
af3a89d
style(mode-nav): format route eligibility tests
BigSimmo Aug 6, 2026
9a91b5c
style(mode-nav): format shared page navigation
BigSimmo Aug 6, 2026
9958a5b
style(mode-nav): format navigation DOM tests
BigSimmo Aug 6, 2026
c8c3c88
fix(mode-nav): narrow page navigation props to honoured behaviour
BigSimmo Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -999,7 +999,8 @@ function GlobalStandaloneSearchShellBody({
section ids. Specifiers and Formulation keep their existing local
Subnav (SpecifierSubnav / FormulationSubnav), so the shared mode bar
is skipped for them to avoid a duplicate row on their workflow routes.
Rendered in normal flow (sticky={false}) so it never contends with
Mode navigation portals into the collapsing header; the "On this page"
bar renders in normal flow (sticky={false}) so it never contends with
the universal collapsing header or page-flow search chrome.
*/}
{!pendingModeNavigation && searchMode !== "specifiers" && searchMode !== "formulation" ? (
Expand All@@ -1008,7 +1009,6 @@ function GlobalStandaloneSearchShellBody({
pathname={pathname}
hasSubmittedSearch={hasSubmittedModeSearch}
searchParamString={searchParamString}
onSearch={() => inputRef.current?.focus({ preventScroll: true })}
sticky={false}
/>
) : null}
Expand Down
33 changes: 2 additions & 31 deletions src/components/formulation/formulation-ui.tsx
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { Info, Network, ShieldCheck } from "lucide-react";

import { InformationPageBreadcrumbs, InformationPageShell } from "@/components/information-page-shell";
import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav";
import { cn, eyebrowText } from "@/components/ui-primitives";

export const formulationCard =
Expand All@@ -17,36 +17,7 @@ export function FormulationBreadcrumbs({ current }: { current?: string }) {
}

export function FormulationSubnav({ active }: { active: "search" | "builder" | "compare" | "map" }) {
const items = [
{ id: "search" as const, label: "Find mechanisms", shortLabel: "Find", href: "/formulation" },
{ id: "builder" as const, label: "Build formulation", shortLabel: "Build", href: "/formulation/builder" },
{ id: "compare" as const, label: "Compare", shortLabel: "Compare", href: "/formulation/compare" },
{ id: "map" as const, label: "Mechanism map", shortLabel: "Map", href: "/formulation/map" },
];

return (
<nav
aria-label="Formulation tools"
className="polished-scroll flex max-w-full gap-1 overflow-x-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] p-1 shadow-[var(--shadow-inset)]"
>
{items.map((item) => (
<Link
key={item.id}
href={item.href}
aria-current={active === item.id ? "page" : undefined}
className={cn(
"inline-flex min-h-tap shrink-0 items-center justify-center rounded-md px-3 text-xs font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:text-sm",
active === item.id
? "bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]"
: "text-[color:var(--text-muted)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text)]",
)}
>
<span className="sm:hidden">{item.shortLabel}</span>
<span className="hidden sm:inline">{item.label}</span>
</Link>
))}
</nav>
);
return <RegistryModeNav modeId="formulation" activeId={active} />;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass the current query string to RegistryModeNav.

This call uses the adapter default of "". A Formulation workflow URL with mechanism, a, b, or template loses that state when a user selects another mode-navigation item.

Thread the encoded route query string through FormulationSubnav and its callers. Add a navigation test that starts with selected mechanisms and a template.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/formulation/formulation-ui.tsx` at line 20, Update the
Formulation navigation flow around FormulationSubnav and its callers to accept
and pass the current encoded route query string into RegistryModeNav instead of
relying on the empty default, preserving mechanism, a, b, and template
parameters when switching modes. Add a navigation test covering a URL with
selected mechanisms and a template.

}

export function MechanismDomainChips({ values, limit }: { values: string[]; limit?: number }) {
Expand Down
9 changes: 4 additions & 5 deletions src/components/mode-nav/mode-nav-portal.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,11 +25,10 @@ import { phoneHeaderCollapseAddonSlotId } from "@/lib/mode-home-composer";
* Falls back to normal flow when no host exists — routes rendered without the
* universal header, and the server pass — so navigation is never lost.
*
* Slot ownership: the addon slot holds ONE page-owned header. `DocumentViewer`
* and the differentials detail page already claim it on phones, so a mode whose
* routes include those pages must not also mount a bar there. `ModeNav` renders
* nothing below two destinations, which is what keeps those modes clear today;
* `tests/mode-nav-contract.test.ts` fails if that stops being true.
* Slot ownership: the addon slot holds ONE page-owned header. Document and
* detail-page owners claim it only on routes where the shared shell suppresses
* ModeNav first; one-destination modes also render nothing below the two-item
* minimum. Contract and route-level tests fail if those ownership gates drift.
*/
export function ModeNavHeaderPortal({ children }: { children: ReactNode }) {
const [host, setHost] = useState<HTMLElement | null>(null);
Expand Down
50 changes: 50 additions & 0 deletions src/components/mode-nav/registry-mode-nav.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
"use client";

import { FileText, GitCompareArrows, ListChecks, Network, Search, type LucideIcon } from "lucide-react";

import { ModeNav, type ModeNavItem } from "@/components/mode-nav/mode-nav";
import { appModeDefinition, type AppModeId } from "@/lib/app-modes";
import { modeSecondaryNavigationEntries, modeSecondaryNavigationHref } from "@/lib/mode-secondary-navigation";

const iconByItemId: Record<string, LucideIcon> = {
search: Search,
diagnoses: FileText,
compare: GitCompareArrows,
builder: ListChecks,
map: Network,
};

/**
* Adapts the canonical route registry to the universal header-integrated mode
* navigation. Keeping this mapping in one component prevents the page shell,
* Specifiers, and Formulation from drifting onto different labels or URLs.
*/
export function RegistryModeNav({
modeId,
activeId,
searchParamString = "",
}: {
modeId: AppModeId;
activeId: string;
searchParamString?: string;
}) {
const currentSearchParams = new URLSearchParams(searchParamString);
const items = modeSecondaryNavigationEntries(modeId).flatMap<ModeNavItem>((entry) => {
if (!entry.href) return [];
return [
{
id: entry.id,
label: entry.label,
href: modeSecondaryNavigationHref({
modeId,
itemId: entry.id,
href: entry.href,
currentSearchParams,
}),
icon: iconByItemId[entry.id] ?? FileText,
},
];
});

return <ModeNav items={items} label={`${appModeDefinition(modeId).label} pages`} activeId={activeId} />;
}
77 changes: 18 additions & 59 deletions src/components/page-secondary-navigation.tsx
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";

import { isDocumentViewerOwnedRoute } from "@/components/clinical-dashboard/mobile-composer-reserve";
import {
SecondaryNavigation,
type SecondaryNavigationItem,
type SecondaryNavigationSectionItem,
} from "@/components/secondary-navigation";
import { appModeDefinition, type AppModeId } from "@/lib/app-modes";
import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav";
import { SecondaryNavigation, type SecondaryNavigationSectionItem } from "@/components/secondary-navigation";
import type { AppModeId } from "@/lib/app-modes";
import { isInformationPage } from "@/lib/information-pages";
import {
activeModeSecondaryNavigationId,
isModeSecondaryNavigationRoute,
modeSecondaryNavigationEntries,
modeSecondaryNavigationHref,
} from "@/lib/mode-secondary-navigation";
import { activeModeSecondaryNavigationId, isModeSecondaryNavigationRoute } from "@/lib/mode-secondary-navigation";

export type InformationPageSectionDefinition = {
id: string;
Expand DownExpand Up@@ -152,15 +144,19 @@ export function informationPageSectionDefinitions(pathname: string): readonly In
if (
pathname.startsWith("/specifiers/") &&
!["/specifiers/builder", "/specifiers/compare", "/specifiers/map"].includes(pathname)
)
) {
return specifierSections;
}
if (
pathname.startsWith("/formulation/") &&
!["/formulation/builder", "/formulation/compare", "/formulation/map"].includes(pathname)
)
) {
return formulationSections;
}
if (pathname.startsWith("/differentials/presentations/")) return differentialPresentationSections;
if (pathname.endsWith("/differentials") && pathname.startsWith("/dsm/diagnoses/")) return dsmDifferentialSections;
if (pathname.endsWith("/differentials") && pathname.startsWith("/dsm/diagnoses/")) {
return dsmDifferentialSections;
}
if (pathname.startsWith("/dsm/diagnoses/")) return dsmDiagnosisSections;
if (pathname.startsWith("/documents/") && pathname !== "/documents/search") return documentSections;
return [];
Expand DownExpand Up@@ -248,57 +244,28 @@ export function PageSecondaryNavigation({
modeId,
pathname,
hasSubmittedSearch,
onSearch,
/**
* Bridged query string from GlobalStandaloneSearchShellBody. Must not call
* useSearchParams here — that reintroduces a nested Suspense boundary under
* the standalone shell body (search-chrome invariant 17).
*/
searchParamString = "",
/**
* Only reaches the "On this page" bar. The mode bar is the header-integrated
* `ModeNav`, which portals into the collapsing header and owns its own
* placement, so no positioning prop is accepted for it.
*/
sticky = true,
stickyTop,
}: {
modeId: AppModeId;
pathname: string;
hasSubmittedSearch: boolean;
onSearch: () => void;
searchParamString?: string;
sticky?: boolean;
stickyTop?: number | string;
}) {
const informationDefinitions = informationPageSectionDefinitions(pathname);
const locallyOwnedInformationNavigation = hasLocalInformationPageNavigation(pathname);
const activeId = activeModeSecondaryNavigationId(modeId, pathname);
const modeLabel = appModeDefinition(modeId).label;
const modeAriaLabel = modeLabel.toLowerCase().endsWith("mode") ? modeLabel : `${modeLabel} mode`;
const modeItems = useMemo<SecondaryNavigationItem[]>(
() =>
modeSecondaryNavigationEntries(modeId).map((entry) =>
entry.href
? {
kind: "route" as const,
id: entry.id,
label: entry.label,
shortLabel: entry.shortLabel,
href: modeSecondaryNavigationHref({
modeId,
itemId: entry.id,
href: entry.href,
currentSearchParams: new URLSearchParams(searchParamString),
}),
current: entry.id === activeId,
}
: {
kind: "action" as const,
id: entry.id,
label: entry.label,
shortLabel: entry.shortLabel,
onSelect: onSearch,
current: entry.id === activeId,
},
),
[activeId, modeId, onSearch, searchParamString],
);

// Therapy Compass owns both its workflow bindings and its dynamic detail
// sections inside TcProvider; rendering the shell registry as well would
Expand All@@ -309,13 +276,5 @@ export function PageSecondaryNavigation({
return <AvailableInformationPageNavigation definitions={informationDefinitions} sticky={sticky} />;
}
if (!isModeSecondaryNavigationRoute({ modeId, pathname, hasSubmittedSearch })) return null;
return (
<SecondaryNavigation
ariaLabel={modeAriaLabel}
items={modeItems}
activeId={activeId}
sticky={sticky}
stickyTop={stickyTop}
/>
);
return <RegistryModeNav modeId={modeId} activeId={activeId} searchParamString={searchParamString} />;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
37 changes: 2 additions & 35 deletions src/components/specifiers/specifier-ui.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import type { ComponentType, CSSProperties, ReactNode } from "react";
import { ArrowRight, CheckCircle2, ChevronsUpDown, Info, Minus, ShieldAlert, Tags } from "lucide-react";

import { InformationPageBreadcrumbs, InformationPageShell } from "@/components/information-page-shell";
import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav";
import { cn, eyebrowText } from "@/components/ui-primitives";
import type { SpecifierFamily, SpecifierRecord } from "@/lib/specifiers";
import { specifierFamilies } from "@/lib/specifiers";
Expand All@@ -20,41 +21,7 @@ export function SpecifierBreadcrumbs({ current }: { current?: string }) {
}

export function SpecifierSubnav({ active }: { active: "search" | "builder" | "compare" | "map" }) {
const items = [
{ id: "search" as const, label: "Find", shortLabel: "Find", href: "/specifiers" },
{ id: "builder" as const, label: "Build wording", shortLabel: "Build", href: "/specifiers/builder" },
{ id: "compare" as const, label: "Compare", shortLabel: "Compare", href: "/specifiers/compare" },
{ id: "map" as const, label: "Map", shortLabel: "Map", href: "/specifiers/map" },
];

return (
<nav
aria-label="Specifier tools"
className="polished-scroll flex max-w-full gap-1 overflow-x-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] p-1 shadow-[var(--shadow-inset)]"
>
{items.map((item) => (
<Link
key={item.id}
href={item.href}
aria-label={item.label}
aria-current={active === item.id ? "page" : undefined}
className={cn(
"inline-flex min-h-tap shrink-0 items-center justify-center rounded-md px-3 text-xs font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:text-sm",
active === item.id
? "bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]"
: "text-[color:var(--text-muted)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text)]",
)}
>
<span className="sm:hidden" aria-hidden>
{item.shortLabel}
</span>
<span className="hidden sm:inline" aria-hidden>
{item.label}
</span>
</Link>
))}
</nav>
);
return <RegistryModeNav modeId="specifiers" activeId={active} />;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass the current query string to RegistryModeNav.

This call uses the adapter default of "". A Specifiers workflow URL with selected specifiers loses those selections when the user changes workflow pages.

Thread the encoded route query string through SpecifierSubnav and its callers. Add a navigation test with builder, compare, and map selection parameters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/specifiers/specifier-ui.tsx` at line 24, Pass the current
encoded route query string through SpecifierSubnav and all of its callers into
RegistryModeNav instead of relying on the empty default, preserving builder,
compare, and map selection parameters when navigating workflow pages. Add a
navigation test covering these three selection parameters.

}
Comment on lines 23 to 25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Selections are dropped when moving between Specifiers or Formulation workflow pages

The Specifiers and Formulation page menus are built without the page's current query string (<RegistryModeNav modeId="specifiers" activeId={active} /> at src/components/specifiers/specifier-ui.tsx:24 and the matching call at src/components/formulation/formulation-ui.tsx:20), so every menu link points at a bare page and the work already in progress is not carried across.
Impact: A clinician who has picked specifiers or mechanisms and then taps Build, Compare or Map from the header menu arrives at an empty page and has to re-pick everything.

Why the state-carrying helper never runs for these two modes

RegistryModeNav defaults searchParamString to "" (src/components/mode-nav/registry-mode-nav.tsx:25) and feeds that into modeSecondaryNavigationHref via new URLSearchParams("") (src/components/mode-nav/registry-mode-nav.tsx:31-43). That helper exists precisely to translate specifier=/a=/b=/selected= and mechanism=/template= into each destination URL (src/lib/mode-secondary-navigation.ts:168-247), but with empty params every branch degrades to the bare registry href.

The shared shell path does pass the bridged query string (src/components/page-secondary-navigation.tsx:279), yet GlobalSearchShell deliberately skips PageSecondaryNavigation for searchMode === "specifiers" | "formulation" (src/components/clinical-dashboard/global-search-shell.tsx:1005), so SpecifierSubnav/FormulationSubnav are the only owners of the bar on those routes and the translation is dead code there. Passing the current search params (e.g. via useSearchParams().toString() in a client wrapper, or threading the page's params down) would restore the intended behaviour.

Prompt for agents
SpecifierSubnav (src/components/specifiers/specifier-ui.tsx) and FormulationSubnav (src/components/formulation/formulation-ui.tsx) now delegate to RegistryModeNav but never supply searchParamString, which defaults to an empty string in src/components/mode-nav/registry-mode-nav.tsx. As a result modeSecondaryNavigationHref (src/lib/mode-secondary-navigation.ts) receives empty URLSearchParams and cannot carry the specifier/mechanism/template/a/b selection state between Find, Build, Compare and Map. Since GlobalSearchShell skips PageSecondaryNavigation for the specifiers and formulation modes, these two Subnavs are the only owners of the mode bar on those routes, so the state-carrying logic never runs at all for them. Consider giving these Subnavs access to the current query string (for example a small client wrapper reading useSearchParams, or threading the page's searchParams through the page components that render them) and passing it to RegistryModeNav, keeping the search-chrome invariant about not introducing a nested Suspense boundary under the standalone shell body in mind.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const familyChipBase =
Expand Down
41 changes: 30 additions & 11 deletions src/lib/mode-secondary-navigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,25 +91,42 @@ export function isModeSecondaryNavigationRoute(params: {
hasSubmittedSearch: boolean;
}): boolean {
const { modeId, pathname, hasSubmittedSearch } = params;

// A one-destination mode has no meaningful secondary choice. Suppress it
// even after search submission rather than rendering a redundant one-item
// strip beneath the universal header.
if (modeSecondaryNavigationRegistry[modeId].length < 2) return false;
if (hasSubmittedSearch) return true;

// /documents/search is the documents mode home (composer already visible); do
// not add a lone Search focus control until a query has been submitted.
if (modeId === "documents") return false;
if (modeId === "differentials") {
return pathname === "/differentials/diagnoses" || pathname === "/differentials/presentations";
return (
pathname === "/differentials" ||
pathname === "/differentials/diagnoses" ||
pathname === "/differentials/presentations"
);
}
if (modeId === "dsm") {
return pathname === "/dsm" || pathname === "/dsm/search" || pathname === "/dsm/compare";
}
if (modeId === "dsm") return pathname === "/dsm/search" || pathname === "/dsm/compare";
if (modeId === "specifiers") {
return pathname === "/specifiers/builder" || pathname === "/specifiers/compare" || pathname === "/specifiers/map";
return (
pathname === "/specifiers" ||
pathname === "/specifiers/builder" ||
pathname === "/specifiers/compare" ||
pathname === "/specifiers/map"
);
}
if (modeId === "formulation") {
return (
pathname === "/formulation/builder" || pathname === "/formulation/compare" || pathname === "/formulation/map"
pathname === "/formulation" ||
pathname === "/formulation/builder" ||
pathname === "/formulation/compare" ||
pathname === "/formulation/map"
);
}
if (modeId === "factsheets") return pathname === "/factsheets/search";
if (modeId === "therapy-compass") return pathname !== "/therapy-compass";
if (modeId === "therapy-compass") {
return pathname === "/therapy-compass" || pathname.startsWith("/therapy-compass/");
}
return false;
}

Expand DownExpand Up@@ -172,11 +189,12 @@ export function modeSecondaryNavigationHref(params: {
currentSearchParams.get("b"),
currentSearchParams.get("selected"),
]);
if (itemId === "builder")
if (itemId === "builder") {
return navigationHrefWithParams(
href,
selections.map((value) => ["specifier", value] as const),
);
}
if (itemId === "compare") {
return navigationHrefWithParams(
href,
Expand All@@ -203,11 +221,12 @@ export function modeSecondaryNavigationHref(params: {
]);
const template = currentSearchParams.get("template");
const templateEntry: Array<readonly [string, string]> = template ? [["template", template]] : [];
if (itemId === "builder")
if (itemId === "builder") {
return navigationHrefWithParams(href, [
...selections.map((value) => ["mechanism", value] as const),
...templateEntry,
]);
}
if (itemId === "compare") {
return navigationHrefWithParams(href, [
...selections.slice(0, 2).map((value, index) => [index === 0 ? "a" : "b", value] as const),
Expand Down
Loading
Loading