Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 13 additions & 3 deletions src/components/clinical-dashboard/ClinicalSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,18 @@ const sidebarToolItems = [
{ id: "answer", label: "Answer", icon: Sparkles, href: "/?mode=answer" },
{ id: "documents", label: "Documents", icon: FileText, href: "/?mode=documents" },
{ id: "services", label: "Services", icon: appModeIcons.services, href: "/services" },
{ id: "forms", label: "Forms", icon: ClipboardPen, href: "/forms" },
// badge = catalogue-maturity pill: the Forms registry is a small starter set.
{ id: "forms", label: "Forms", icon: ClipboardPen, href: "/forms", badge: "Early access" },
{ id: "favourites", label: "Favourites", icon: Heart, href: "/favourites" },
{ id: "differentials", label: "Differentials", icon: BrainCircuit, href: "/differentials" },
{ id: "prescribing", label: "Medications", icon: Pill, href: "/?mode=prescribing" },
{ id: "tools", label: "Tools", icon: Wrench, href: "/?mode=tools" },
] as const;

function sidebarItemBadge(item: (typeof sidebarToolItems)[number]): string | undefined {
return "badge" in item ? item.badge : undefined;
}

// Display-free base so callers can compose `grid` / `hidden lg:grid` without
// conflicting display utilities (cn does not de-duplicate classes).
const collapsedSidebarControl =
Expand Down Expand Up @@ -251,6 +256,11 @@ export function ClinicalSidebarContent({
)}
/>
<span className="min-w-0 flex-1 truncate text-left">{item.label}</span>
{sidebarItemBadge(item) ? (
<span className="shrink-0 rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] px-1.5 py-0.5 text-2xs font-semibold text-[color:var(--text-soft)]">
{sidebarItemBadge(item)}
</span>
) : null}
</Link>
);
})}
Expand Down Expand Up @@ -420,8 +430,8 @@ function ClinicalCollapsedRail({
onFocus={item.id === "tools" ? onPrefetchApplications : undefined}
onPointerEnter={item.id === "tools" ? onPrefetchApplications : undefined}
className={cn(collapsedSidebarButton, active && collapsedSidebarActiveButton)}
aria-label={item.label}
title={item.label}
aria-label={sidebarItemBadge(item) ? `${item.label} (${sidebarItemBadge(item)})` : item.label}
title={sidebarItemBadge(item) ? `${item.label} (${sidebarItemBadge(item)})` : item.label}
aria-current={active ? "page" : undefined}
>
<Icon className="h-4 w-4" />
Expand Down
24 changes: 20 additions & 4 deletions src/components/clinical-dashboard/use-sidebar-collapsed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,28 @@ import { useCallback, useSyncExternalStore } from "react";
const storageKey = "clinical-kb-sidebar-collapsed";
const changeEvent = "clinical-kb-sidebar-collapsed-change";

// In-memory fallback when localStorage writes fail (e.g. private browsing mode).
// Null means no fallback needed; storage is the source of truth.
let inMemoryFallback: boolean | null = null;

function getSnapshot() {
// When storage writes have failed, the in-memory fallback takes precedence
// so the UI reflects the user's toggle even when persistence is unavailable.
if (inMemoryFallback !== null) {
return inMemoryFallback;
}
try {
const storedValue = window.localStorage.getItem(storageKey);
return storedValue === null ? true : storedValue === "1";
// New users get the labelled (expanded) sidebar: eight icon-only
// destinations demand recall/hover; collapsing stays a remembered choice.
return storedValue === null ? false : storedValue === "1";
Comment thread
BigSimmo marked this conversation as resolved.
} catch {
return true;
return false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

function getServerSnapshot() {
return true;
return false;
}

function subscribe(onChange: () => void) {
Expand All @@ -37,8 +48,13 @@ export function useSidebarCollapsed() {
const setCollapsed = useCallback((next: boolean) => {
try {
window.localStorage.setItem(storageKey, next ? "1" : "0");
// Storage write succeeded; clear the in-memory fallback so persisted
// storage remains the source of truth.
inMemoryFallback = null;
} catch {
// Ignore storage failures (private mode); state simply won't persist.
// Storage write failed (private mode, quota exceeded, etc.); remember
// the requested state in memory so the UI can reflect the toggle.
inMemoryFallback = next;
}
window.dispatchEvent(new Event(changeEvent));
}, []);
Expand Down
2 changes: 1 addition & 1 deletion src/components/forms/forms-home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function FormsHomePage() {
<ModeHomeTemplate
testId="forms-home-template"
title="Forms"
subtitle="Search, check, or follow a pathway."
subtitle="Early access: a starter set of MHA 2014 forms. Search, check, or follow a pathway."
icon={FileText}
desktopComposerSlotId={modeHomeDesktopComposerSlotId}
actionsLabel="Forms tasks"
Expand Down
26 changes: 24 additions & 2 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,9 +885,25 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByTestId("global-search-input")).toBeEnabled();
});

test("desktop sidebar defaults to the labelled state for new users", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
await gotoApp(page, "/?mode=answer");
await waitForDemoDashboardReady(page);

// No stored preference (PT-10): eight icon-only destinations demand recall,
// so first-run desktop shows the labelled sidebar; collapse is remembered.
await expect(page.locator("#clinical-tools-sidebar")).toBeVisible();
await expect(page.getByRole("button", { name: "Collapse sidebar" })).toBeVisible();
await expect(page.getByRole("button", { name: "Expand sidebar" })).toHaveCount(0);
});

test("desktop sidebar mode sync and accessibility affordances stay coherent", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
// This journey exercises the remembered-collapsed rail; new users now
// default to the labelled sidebar, so seed the stored preference.
await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1"));
await gotoApp(page, "/?mode=tools");

const sidebar = page.locator("#clinical-tools-sidebar");
Expand Down Expand Up @@ -936,14 +952,17 @@ test.describe("Clinical KB UI smoke coverage", () => {

await expect(page.getByRole("button", { name: "Open Clinical Guide menu" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Expand sidebar" })).toHaveCount(0);
await expect(page.locator("#clinical-tools-sidebar")).toHaveCount(0);
// With the labelled default the expanded panel exists in the DOM but stays
// display:none below lg; tablet must still only present the icon rail.
await expect(page.locator("#clinical-tools-sidebar")).toBeHidden();
await expect(page.getByLabel("Clinical Guide collapsed sidebar")).toBeVisible();

for (const tool of [
{ name: "Answer", href: "/?mode=answer" },
{ name: "Documents", href: "/?mode=documents" },
{ name: "Services", href: "/services" },
{ name: "Forms", href: "/forms" },
// The rail speaks the catalogue-maturity badge as part of the Forms name.
{ name: "Forms (Early access)", href: "/forms" },
{ name: "Favourites", href: "/favourites" },
{ name: "Differentials", href: "/differentials" },
{ name: "Medications", href: "/?mode=prescribing" },
Expand Down Expand Up @@ -1018,6 +1037,9 @@ test.describe("Clinical KB UI smoke coverage", () => {
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
// Exercises both collapsed and expanded account affordances; seed the
// remembered-collapsed preference now that new users default to labelled.
await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1"));
await gotoApp(page, "/");
await waitForDemoDashboardReady(page);

Expand Down
13 changes: 10 additions & 3 deletions tests/ui-tools-collapse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ async function goto(page: Page, path: string) {
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
}

// The shell's expanded sidebar (now the desktop default) contributes its own
// "Search recent chats" searchbox, so mockup searches must be scoped to the
// page content instead of grabbing the first searchbox on the page.
function mockupSearch(page: Page) {
return page.locator("#main-content").getByRole("searchbox").first();
}

test.describe("Tools mockups collapse the primary region when filtering", () => {
test.describe.configure({ timeout: 60_000 });

Expand All @@ -18,7 +25,7 @@ test.describe("Tools mockups collapse the primary region when filtering", () =>

await expect(page.getByRole("heading", { name: "Start here" })).toBeVisible();

const search = page.getByRole("searchbox").first();
const search = mockupSearch(page);
await search.fill("medication");
await expect(page.getByRole("heading", { name: "Start here" })).toHaveCount(0);
await expect(page.getByLabel("Open Medication Prescribing")).toBeVisible();
Expand All @@ -36,7 +43,7 @@ test.describe("Tools mockups collapse the primary region when filtering", () =>
await expect(page.getByRole("heading", { name: "Resume" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Assess" })).toBeVisible();

await page.getByRole("searchbox").first().fill("medication");
await mockupSearch(page).fill("medication");
await expect(page.getByRole("heading", { name: "Assess" })).toHaveCount(0);
await expect(page.getByLabel("Open Medication Prescribing")).toBeVisible();
});
Expand All @@ -47,7 +54,7 @@ test.describe("Tools mockups collapse the primary region when filtering", () =>

await expect(page.getByRole("heading", { name: "Launcher overview" })).toBeVisible();

await page.getByRole("searchbox").first().fill("medication");
await mockupSearch(page).fill("medication");
await expect(page.getByRole("heading", { name: "Launcher overview" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Results" })).toBeVisible();
// The split-pane card is a button; it must expose an explicit accessible
Expand Down
3 changes: 3 additions & 0 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,9 @@ test.describe("Clinical KB tools launcher", () => {

test("mode toggle stays global on the services home route", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
// Asserts the collapsed rail affordance below; seed the remembered
// preference now that new users default to the labelled sidebar.
await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1"));
await gotoLauncher(page, "/?mode=answer");

// Re-open + re-click on each retry: a single click can be swallowed while the
Expand Down