From a07d70b1073652e2079551ecb5983bc100d4e2cc Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 1 Oct 2026 11:42:49 +0900 Subject: [PATCH] feat: persist sidebar fold preference across logins --- .../console_history_shell_test.ts | 15 +++- .../console/worker-console.ui.test.ts | 2 +- web/workspace/src/routes/+layout.svelte | 22 ++++- .../src/routes/sidebar-fold.browser.spec.ts | 82 +++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 web/workspace/src/routes/sidebar-fold.browser.spec.ts diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts index 156368585..8bce4e77a 100644 --- a/tools/web-ux/browser-tests/console_history_shell_test.ts +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -24,7 +24,9 @@ async function freePort(): Promise { async function waitForServer(url: string): Promise { for (let attempt = 0; attempt < 200; attempt += 1) { try { - if ((await fetch(url)).ok) return; + const response = await fetch(url); + await response.body?.cancel(); + if (response.ok) return; } catch { // Retry until the bounded deadline while the owned server starts. } @@ -334,6 +336,17 @@ async function checkConsoleHistory(viewportHeight: number): Promise { assertEquals(new Set(rowIds).size, rowIds.length); await page.getByRole("button", { name: "Overview", exact: true }).click(); assertEquals(await transcript.getByText("searched 1 time惻ran 1 command", { exact: true }).count(), 12); + await page.getByRole("button", { name: "Fold sidebar", exact: true }).click(); + assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "true"); + await page.reload(); + await page.getByRole("button", { name: "Unfold sidebar", exact: true }).waitFor(); + assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0); + await page.setViewportSize({ width: 600, height: viewportHeight }); + await page.getByRole("button", { name: "Show sidebar", exact: true }).click(); + assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "false"); + await page.reload(); + await page.getByRole("button", { name: "Hide sidebar", exact: true }).waitFor(); + assertEquals(await page.locator(".app-shell.sidebar-open").count(), 1); assertEquals(errors, []); await context.close(); } finally { diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 902c3ef29..4c0640485 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -1329,7 +1329,7 @@ Deno.test("Account UI owns browser passkey session state without workspace autho sidebarFrame.includes("sidebar-fold-button") && sidebarFrame.includes("Fold sidebar") && sidebarFrame.includes("Unfold sidebar") && - rootLayout.includes("let sidebarFolded = $state(false)") && + rootLayout.includes("let sidebarFolded = $state(loadSidebarFolded())") && rootLayout.includes("") && !workspaceLayout.includes("sidebarFolded") && !workspaceLayout.includes("onToggleFold") && diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 7da18c61e..e68c31e18 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -24,7 +24,27 @@ const headerOverrides = createOverrideStack((activeHeader) => { header = activeHeader; }); - let sidebarFolded = $state(false); + // Browser-wide layout preference; independent of authentication and Workspace. + const sidebarFoldStorageKey = 'yoi.sidebar.folded.v1'; + function loadSidebarFolded(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(sidebarFoldStorageKey) === 'true'; + } catch { + return false; + } + } + + let sidebarFolded = $state(loadSidebarFolded()); + + $effect(() => { + const folded = sidebarFolded; + try { + window.localStorage.setItem(sidebarFoldStorageKey, String(folded)); + } catch { + // Storage can be blocked or full; folding must still work for this page. + } + }); function toggleSidebar() { sidebarFolded = !sidebarFolded; diff --git a/web/workspace/src/routes/sidebar-fold.browser.spec.ts b/web/workspace/src/routes/sidebar-fold.browser.spec.ts new file mode 100644 index 000000000..c50b541ed --- /dev/null +++ b/web/workspace/src/routes/sidebar-fold.browser.spec.ts @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { createRawSnippet } from "svelte"; +import { logout } from "$lib/workspace/auth/api"; +import Layout from "./+layout.svelte"; + +vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } })); + +const storageKey = "yoi.sidebar.folded.v1"; +const props = { + params: {}, + children: createRawSnippet(() => ({ render: () => "

Page content

" })), + data: { accessibleWorkspaces: [], workspaceCatalogError: null }, +}; + +beforeEach(() => localStorage.clear()); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +test("sidebar starts unfolded and persists both desktop and mobile toggles", async () => { + const view = render(Layout, { props }); + expect(screen.getByRole("button", { name: "Fold sidebar" }).getAttribute("aria-expanded")).toBe("true"); + await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); + expect(localStorage.getItem(storageKey)).toBe("true"); + expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(false); + await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); + expect(localStorage.getItem(storageKey)).toBe("false"); + expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" })); + expect(localStorage.getItem(storageKey)).toBe("true"); + await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" })); + expect(localStorage.getItem(storageKey)).toBe("false"); +}); + +test.each([true, false])("restores stored folded=%s without overwriting it with the default", (folded) => { + localStorage.setItem(storageKey, String(folded)); + const writes = vi.spyOn(Storage.prototype, "setItem"); + const view = render(Layout, { props }); + expect(screen.getByRole("button", { name: folded ? "Unfold sidebar" : "Fold sidebar" }).getAttribute("aria-expanded")).toBe(String(!folded)); + expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(!folded); + expect(writes.mock.calls.every(([key, value]) => key !== storageKey || value === String(folded))).toBe(true); +}); + +test("fold preference survives logout and a fresh app mount", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" }))); + const view = render(Layout, { props }); + await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); + await logout(); + view.unmount(); + render(Layout, { props }); + expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); + expect(localStorage.getItem(storageKey)).toBe("true"); +}); + +test("invalid storage falls back to unfolded", () => { + localStorage.setItem(storageKey, "not-a-boolean"); + render(Layout, { props }); + expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); +}); + +test("unavailable storage does not prevent folding", async () => { + vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); }); + render(Layout, { props }); + await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); + expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); + expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); +}); + +test("full storage still restores the saved value and allows toggles", async () => { + localStorage.setItem(storageKey, "true"); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); }); + render(Layout, { props }); + await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" })); + expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); +});