Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2d7ea51
fix(sidebar): default the desktop sidebar to collapsed
claude Aug 6, 2026
f101a40
fix(test): assert the collapsed rail, not the absent expanded panel
claude Aug 6, 2026
aef0d25
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 6, 2026
65f81cf
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 6, 2026
74c3dd1
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 6, 2026
691c878
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 6, 2026
4609400
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 6, 2026
a0eec32
fix(test): seed sidebar preference in journeys vacated by default flip
cursoragent Aug 6, 2026
baeb010
Merge remote branch tip into bugbot test fixes
cursoragent Aug 6, 2026
7fd7e98
docs(sidebar): clarify null preference collapses for returning browsers
cursoragent Aug 6, 2026
ea93d0b
chore(ci): retrigger PR checks after Actions infra flake
cursoragent Aug 6, 2026
493b4f3
fix(sidebar): harden default-collapsed coverage after review (#1637)
cursoragent Aug 6, 2026
84ce8fe
Merge remote PR tip into review fixes
cursoragent Aug 6, 2026
bf2159b
Merge origin/main into sidebar default PR
cursoragent Aug 6, 2026
8fd65e8
chore(ci): retrigger checks after stuck Actions run (#1637)
cursoragent Aug 6, 2026
d481c87
chore(ci): retrigger required checks after Actions outage (#1637)
cursoragent Aug 6, 2026
fa617c0
chore(ci): retrigger required checks (Actions gradual recovery) (#1637)
cursoragent Aug 6, 2026
74f58f5
ci: retrigger after Actions queue timeout
cursoragent Aug 6, 2026
b3c4500
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 6, 2026
7bfb141
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 6, 2026
091c7f5
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 7, 2026
f8e6d68
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 7, 2026
79826ea
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 7, 2026
6b7df2a
fix(ui-smoke): expand sidebar before desktop settings scroll test
cursoragent Aug 7, 2026
b324f38
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 7, 2026
5847fbd
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 7, 2026
4f20f6d
Merge branch 'main' into claude/sidebar-closed-default-i6n2fk
BigSimmo Aug 7, 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
33 changes: 25 additions & 8 deletions src/components/clinical-dashboard/use-sidebar-collapsed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,23 @@
import { useCallback } from "react";
import { createBrowserStore } from "@/lib/client-store-factory";

const storageKey = "clinical-kb-sidebar-collapsed";
/** localStorage key for an explicit expanded/collapsed pin. */
export const SIDEBAR_COLLAPSED_STORAGE_KEY = "clinical-kb-sidebar-collapsed";

const changeEvent = "clinical-kb-sidebar-collapsed-change";

/**
* Maps a raw stored value to the collapsed preference. Absent key (`null` /
* `undefined`) resolves to collapsed so first-run and never-toggled browsers
* match the closed-by-default product choice. When a value is present,
* collapsed is true only for the explicit `"1"` pin (same ternary the store
* used before this helper was extracted). Storage errors are handled by
* callers (they pass null / use their own catch path).
*/
export function readSidebarCollapsedPreference(storedValue: string | null | undefined): boolean {
return storedValue === null || storedValue === undefined ? true : storedValue === "1";
}

// 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;
Expand All @@ -17,12 +31,15 @@ function getSnapshot() {
return inMemoryFallback;
}
try {
const storedValue = window.localStorage.getItem(storageKey);
// 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";
const storedValue = window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
// Collapsed is the default whenever the key is absent. The key is only
// written after an explicit toggle, so browsers that never touched the
// control (returning users included) also land on collapsed — matching
// the already-closed mobile drawer. Explicit "0"/"1" preferences still
// win; expanding remains a remembered choice.
return readSidebarCollapsedPreference(storedValue);
} catch {
return false;
return true;
}
}

Expand All @@ -35,7 +52,7 @@ function subscribe(onChange: () => void) {
};
}

const useSidebarCollapsedStore = createBrowserStore(subscribe, getSnapshot, false);
const useSidebarCollapsedStore = createBrowserStore(subscribe, getSnapshot, true);
Comment thread
BigSimmo marked this conversation as resolved.

/**
* Desktop sidebar collapse state shared across shells and persisted per
Expand All @@ -46,7 +63,7 @@ export function useSidebarCollapsed() {
const collapsed = useSidebarCollapsedStore();
const setCollapsed = useCallback((next: boolean) => {
try {
window.localStorage.setItem(storageKey, next ? "1" : "0");
window.localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, next ? "1" : "0");
// Storage write succeeded; clear the in-memory fallback so persisted
// storage remains the source of truth.
inMemoryFallback = null;
Expand Down
32 changes: 32 additions & 0 deletions tests/sidebar-collapsed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
readSidebarCollapsedPreference,
SIDEBAR_COLLAPSED_STORAGE_KEY,
} from "../src/components/clinical-dashboard/use-sidebar-collapsed";

describe("readSidebarCollapsedPreference", () => {
it("defaults to collapsed when the preference key is absent", () => {
expect(readSidebarCollapsedPreference(null)).toBe(true);
expect(readSidebarCollapsedPreference(undefined)).toBe(true);
});

it("honours an explicit expanded pin", () => {
expect(readSidebarCollapsedPreference("0")).toBe(false);
});

it("honours an explicit collapsed pin", () => {
expect(readSidebarCollapsedPreference("1")).toBe(true);
});

it('only treats an explicit "1" as collapsed when a value is present', () => {
// Preserves the pre-existing ternary: absent → collapsed; otherwise
// collapsed iff the stored pin is exactly "1". Stale/garbage pins expand.
expect(readSidebarCollapsedPreference("")).toBe(false);
expect(readSidebarCollapsedPreference("true")).toBe(false);
expect(readSidebarCollapsedPreference("collapsed")).toBe(false);
});

it("pins the storage key used by Playwright seeds and the browser store", () => {
expect(SIDEBAR_COLLAPSED_STORAGE_KEY).toBe("clinical-kb-sidebar-collapsed");
});
});
16 changes: 13 additions & 3 deletions tests/ui-accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,18 @@ test.describe("Clinical KB accessibility coverage", () => {
await page.setViewportSize({ width: 1280, height: 800 });
await mockMinimalDashboardApi(page);

// New chat lives on both the collapsed rail and the expanded panel. With
// collapsed-by-default the expanded panel is unmounted, so scope to the
// rail rather than relying on .first() (same hazard the forced-colors
// journey below guards against).
const railNewChat = page.getByLabel("Clinical Guide collapsed sidebar").getByRole("button", { name: "New chat" });

// Reduced motion → every scripted scroll must be an instant "auto" jump.
await page.emulateMedia({ reducedMotion: "reduce" });
await gotoApp(page);
await expectDashboardUsable(page);
await resetBehaviours();
await page.getByRole("button", { name: "New chat" }).first().click();
await railNewChat.click();
await expect.poll(readBehaviours).not.toHaveLength(0);
const reduced = await readBehaviours();
expect(reduced, "reduced motion must not animate scripted scrolls").not.toContain("smooth");
Expand All @@ -247,7 +253,7 @@ test.describe("Clinical KB accessibility coverage", () => {
// No preference → the same action animates smoothly.
await page.emulateMedia({ reducedMotion: "no-preference" });
await resetBehaviours();
await page.getByRole("button", { name: "New chat" }).first().click();
await railNewChat.click();
await expect.poll(readBehaviours).not.toHaveLength(0);
expect(await readBehaviours(), "no-preference should animate scripted scrolls").toContain("smooth");
});
Expand Down Expand Up @@ -374,10 +380,14 @@ test.describe("Clinical KB accessibility coverage", () => {
await page.emulateMedia({ forcedColors: "active" });
await page.setViewportSize({ width: 1440, height: 1000 });
await mockMinimalDashboardApi(page);
// Target the expanded sidebar's solid --command "New chat" button. With the
// collapsed-by-default rail, getByRole("New chat").first() would hit the
// icon-rail control (text-muted), not the solid label this regression guards.
await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "0"));
await gotoApp(page);
await expectDashboardUsable(page);

const newChat = page.getByRole("button", { name: "New chat" }).first();
const newChat = page.locator("#clinical-tools-sidebar").getByRole("button", { name: "New chat" });
await expect(newChat).toBeVisible();
const { canvas, buttonLabelColor, tokenColors } = await newChat.evaluate((button) => {
const probe = document.createElement("span");
Expand Down
5 changes: 4 additions & 1 deletion tests/ui-hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ test.describe("React Hydration Safety", () => {
const scenarios = [
{ name: "dashboard defaults", route: "/", storage: {} },
{
// Seed expanded ("0") so the client preference differs from the
// collapsed SSR/server snapshot after the closed-by-default flip —
// that is the mismatch/re-render path useSyncExternalStore must handle.
name: "dashboard persisted theme and sidebar",
route: "/",
storage: { "clinical-kb-theme": "dark", "clinical-kb-sidebar-collapsed": "1" },
storage: { "clinical-kb-theme": "dark", "clinical-kb-sidebar-collapsed": "0" },
themeCookie: "dark",
},
{
Expand Down
35 changes: 23 additions & 12 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1164,24 +1164,27 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expectNoPageHorizontalOverflow(page);
});

test("desktop sidebar defaults to the labelled state for new users", async ({ page }) => {
test("desktop sidebar defaults to the collapsed 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): the labelled navigation remains the default,
// 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);
// No stored preference (PT-10): the collapsed icon rail is the default,
// so first-run desktop shows the collapsed rail, not the labelled panel;
// expanding is remembered. #clinical-tools-sidebar only mounts when
// expanded, so its absence (not just hidden) is the collapsed signal.
await expect(page.getByLabel("Clinical Guide collapsed sidebar")).toBeVisible();
await expect(page.locator("#clinical-tools-sidebar")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Expand sidebar" })).toBeVisible();
await expect(page.getByRole("button", { name: "Collapse 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.
// This journey starts from the collapsed rail (now the default for new
// users too) and exercises expanding/collapsing it.
await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1"));
await gotoApp(page, "/?mode=tools");

Expand Down Expand Up @@ -1226,13 +1229,16 @@ test.describe("Clinical KB UI smoke coverage", () => {
test("tablet shows icon rail without drawer trigger or expand control @critical", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await mockDemoApi(page);
// Seed expanded preference so #clinical-tools-sidebar mounts. Without this
// seed the panel is absent (count 0) and toBeHidden() would pass vacuously;
// we need the remembered-expanded path where the panel exists but stays
// display:none below lg while tablet still only presents the icon rail.
await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "0"));
await gotoApp(page, "/?mode=answer");
await waitForDemoDashboardReady(page);

await expect(page.getByRole("button", { name: "Open Clinical Guide menu" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Expand 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();

Expand Down Expand Up @@ -1336,8 +1342,8 @@ 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.
// Exercises both collapsed and expanded account affordances; seed collapsed
// explicitly (also the new-user default) so the journey starts on the rail.
await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1"));
await gotoApp(page, "/");
await waitForDemoDashboardReady(page);
Expand Down Expand Up @@ -1379,6 +1385,11 @@ test.describe("Clinical KB UI smoke coverage", () => {
await gotoApp(page, "/");
await waitForDemoDashboardReady(page);

// Sidebar defaults to collapsed for new users; expand so the in-rail Settings
// control this journey asserts is reachable (same as the account-setup case).
await page.getByRole("button", { name: "Expand sidebar" }).click();
await expect(page.locator("#clinical-tools-sidebar")).toBeVisible();

const settings = accountSettingsDialog(page);
await page.locator("#clinical-tools-sidebar").getByRole("button", { name: "Settings", exact: true }).click();
await expect(settings).toBeVisible();
Expand Down
7 changes: 4 additions & 3 deletions tests/ui-tools-collapse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ async function goto(page: Page, path: string) {
await expect(page.locator("#main-content").first()).toBeVisible({ timeout: 15_000 });
}

// 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.
// The shell's sidebar (expanded when a remembered preference restores it)
// 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();
}
Expand Down
4 changes: 2 additions & 2 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,8 +556,8 @@ 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.
// Asserts the collapsed rail affordance below; explicit for clarity even
// though collapsed is now the default for new users too.
await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1"));
await gotoLauncher(page, "/?mode=answer");

Expand Down
Loading