From ae66186db93624cdba2b286c98093c894f7849ee Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Tue, 24 Mar 2026 15:07:02 -0700 Subject: [PATCH 1/5] feat(ui): add sidebar width helpers --- .../components/layout/sidebar-width.test.ts | 62 +++++++++++++++++++ .../lib/components/layout/sidebar-width.ts | 38 ++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 frontend/src/lib/components/layout/sidebar-width.test.ts create mode 100644 frontend/src/lib/components/layout/sidebar-width.ts diff --git a/frontend/src/lib/components/layout/sidebar-width.test.ts b/frontend/src/lib/components/layout/sidebar-width.test.ts new file mode 100644 index 00000000..4b2e5ae6 --- /dev/null +++ b/frontend/src/lib/components/layout/sidebar-width.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + SIDEBAR_CONTENT_MIN, + SIDEBAR_DESKTOP_BREAKPOINT, + SIDEBAR_WIDTH_DEFAULT, + SIDEBAR_WIDTH_KEY, + SIDEBAR_WIDTH_MIN, + SIDEBAR_WIDTH_STORAGE_MAX, + clampSidebarWidthForLayout, + clampStoredSidebarWidth, + isDesktopSidebarLayout, +} from "./sidebar-width.js"; + +describe("sidebar width helpers", () => { + it("exports the expected sidebar width constants", () => { + expect(SIDEBAR_WIDTH_KEY).toBe("agentsview-sidebar-width"); + expect(SIDEBAR_WIDTH_DEFAULT).toBe(260); + expect(SIDEBAR_WIDTH_MIN).toBe(220); + expect(SIDEBAR_WIDTH_STORAGE_MAX).toBe(520); + expect(SIDEBAR_CONTENT_MIN).toBe(480); + expect(SIDEBAR_DESKTOP_BREAKPOINT).toBe(960); + }); + + it("falls back to the default for invalid stored values", () => { + expect(clampStoredSidebarWidth(undefined)).toBe(SIDEBAR_WIDTH_DEFAULT); + expect(clampStoredSidebarWidth(null)).toBe(SIDEBAR_WIDTH_DEFAULT); + expect(clampStoredSidebarWidth("not-a-number")).toBe( + SIDEBAR_WIDTH_DEFAULT, + ); + expect(clampStoredSidebarWidth(Number.NaN)).toBe(SIDEBAR_WIDTH_DEFAULT); + expect(clampStoredSidebarWidth(Number.POSITIVE_INFINITY)).toBe( + SIDEBAR_WIDTH_DEFAULT, + ); + }); + + it("clamps stored values to the supported minimum and maximum", () => { + expect(clampStoredSidebarWidth(100)).toBe(SIDEBAR_WIDTH_MIN); + expect(clampStoredSidebarWidth(260)).toBe(260); + expect(clampStoredSidebarWidth(999)).toBe(SIDEBAR_WIDTH_STORAGE_MAX); + }); + + it("accepts persisted numeric strings from localStorage", () => { + expect(clampStoredSidebarWidth("260")).toBe(260); + expect(clampStoredSidebarWidth("300")).toBe(300); + expect(clampStoredSidebarWidth("999")).toBe(SIDEBAR_WIDTH_STORAGE_MAX); + }); + + it("treats 960px and wider as desktop layout", () => { + expect(isDesktopSidebarLayout(959)).toBe(false); + expect(isDesktopSidebarLayout(960)).toBe(true); + }); + + it("never clamps the layout width below the sidebar minimum", () => { + expect(clampSidebarWidthForLayout(180, 650)).toBe(SIDEBAR_WIDTH_MIN); + expect(clampSidebarWidthForLayout(520, 650)).toBe(SIDEBAR_WIDTH_MIN); + }); + + it("limits sidebar width by the available layout width", () => { + expect(clampSidebarWidthForLayout(520, 700)).toBe(220); + expect(clampSidebarWidthForLayout(520, 900)).toBe(420); + }); +}); diff --git a/frontend/src/lib/components/layout/sidebar-width.ts b/frontend/src/lib/components/layout/sidebar-width.ts new file mode 100644 index 00000000..10cfe331 --- /dev/null +++ b/frontend/src/lib/components/layout/sidebar-width.ts @@ -0,0 +1,38 @@ +export const SIDEBAR_WIDTH_KEY = "agentsview-sidebar-width"; +export const SIDEBAR_WIDTH_DEFAULT = 260; +export const SIDEBAR_WIDTH_MIN = 220; +export const SIDEBAR_WIDTH_STORAGE_MAX = 520; +export const SIDEBAR_CONTENT_MIN = 480; +export const SIDEBAR_DESKTOP_BREAKPOINT = 960; + +export function clampStoredSidebarWidth(value: unknown): number { + const numericValue = + typeof value === "string" && value.trim() !== "" + ? Number(value) + : value; + + if (typeof numericValue !== "number" || !Number.isFinite(numericValue)) { + return SIDEBAR_WIDTH_DEFAULT; + } + + return Math.min( + SIDEBAR_WIDTH_STORAGE_MAX, + Math.max(SIDEBAR_WIDTH_MIN, numericValue), + ); +} + +export function isDesktopSidebarLayout(viewportWidth: number): boolean { + return viewportWidth >= SIDEBAR_DESKTOP_BREAKPOINT; +} + +export function clampSidebarWidthForLayout( + desiredWidth: number, + layoutWidth: number, +): number { + const layoutMaxWidth = Math.max( + SIDEBAR_WIDTH_MIN, + Math.min(SIDEBAR_WIDTH_STORAGE_MAX, layoutWidth - SIDEBAR_CONTENT_MIN), + ); + + return Math.min(layoutMaxWidth, Math.max(SIDEBAR_WIDTH_MIN, desiredWidth)); +} From 5746992904c2c1fe9f67f8a81a6f1b0caea1362a Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Tue, 24 Mar 2026 15:07:08 -0700 Subject: [PATCH 2/5] feat(ui): persist sidebar width preference --- frontend/src/lib/stores/ui.svelte.ts | 32 +++ frontend/src/lib/stores/ui.test.ts | 291 ++++++++++++++++++++++++++- 2 files changed, 314 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index 977e0605..c968ae98 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -1,3 +1,9 @@ +import { + SIDEBAR_WIDTH_DEFAULT, + SIDEBAR_WIDTH_KEY, + clampStoredSidebarWidth, +} from "../components/layout/sidebar-width.js"; + type Theme = "light" | "dark"; export type MessageLayout = "default" | "compact" | "stream"; export type TranscriptMode = "normal" | "focused"; @@ -126,6 +132,16 @@ function readStoredTranscriptMode(): TranscriptMode { return "normal"; } +function readStoredSidebarWidth(): number { + try { + return clampStoredSidebarWidth( + localStorage?.getItem(SIDEBAR_WIDTH_KEY), + ); + } catch { + return SIDEBAR_WIDTH_DEFAULT; + } +} + class UIStore { theme: Theme = $state(readStoredTheme() || "light"); sortNewestFirst: boolean = $state(false); @@ -133,6 +149,7 @@ class UIStore { transcriptMode: TranscriptMode = $state( readStoredTranscriptMode(), ); + sidebarWidth: number = $state(readStoredSidebarWidth()); activeModal: ModalType = $state(null); selectedOrdinal: number | null = $state(null); pendingScrollOrdinal: number | null = $state(null); @@ -186,6 +203,17 @@ class UIStore { } }); + $effect(() => { + try { + localStorage?.setItem( + SIDEBAR_WIDTH_KEY, + String(this.sidebarWidth), + ); + } catch { + // ignore + } + }); + $effect(() => { if (!IS_DESKTOP) return; // "zoom" is non-standard but supported in WebKit/Chromium @@ -306,6 +334,10 @@ class UIStore { this.transcriptMode = mode; } + setSidebarWidth(width: number) { + this.sidebarWidth = clampStoredSidebarWidth(width); + } + selectOrdinal(ordinal: number) { this.selectedOrdinal = ordinal; } diff --git a/frontend/src/lib/stores/ui.test.ts b/frontend/src/lib/stores/ui.test.ts index 9fd240ca..9c1a6909 100644 --- a/frontend/src/lib/stores/ui.test.ts +++ b/frontend/src/lib/stores/ui.test.ts @@ -5,6 +5,13 @@ import { vi, beforeEach, } from "vitest"; +import { tick } from "svelte"; +import { + SIDEBAR_WIDTH_DEFAULT, + SIDEBAR_WIDTH_KEY, + SIDEBAR_WIDTH_MIN, + SIDEBAR_WIDTH_STORAGE_MAX, +} from "../components/layout/sidebar-width.js"; import { ui } from "./ui.svelte.js"; describe("UIStore", () => { @@ -173,6 +180,241 @@ describe("UIStore", () => { }); }); + describe("sidebar width", () => { + it("defaults to the helper default when storage is empty", async () => { + const original = globalThis.localStorage; + const getItem = vi.fn(() => null); + const setItem = vi.fn(); + + Object.defineProperty(globalThis, "localStorage", { + value: { getItem, setItem }, + writable: true, + configurable: true, + }); + + try { + // @ts-expect-error -- query string busts module cache + const mod = await import("./ui.svelte.js?sidebarWidthEmpty"); + expect(getItem.mock.calls).toContainEqual([ + SIDEBAR_WIDTH_KEY, + ]); + expect(mod.ui.sidebarWidth).toBe(SIDEBAR_WIDTH_DEFAULT); + } finally { + Object.defineProperty(globalThis, "localStorage", { + value: original, + writable: true, + configurable: true, + }); + } + }); + + it("reads and clamps stored widths including stored strings", async () => { + const original = globalThis.localStorage; + + try { + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: vi.fn((key: string) => + key === SIDEBAR_WIDTH_KEY + ? String(SIDEBAR_WIDTH_MIN - 50) + : null, + ), + setItem: vi.fn(), + }, + writable: true, + configurable: true, + }); + // @ts-expect-error -- query string busts module cache + const minMod = await import("./ui.svelte.js?sidebarWidthStoredMin"); + + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: vi.fn((key: string) => + key === SIDEBAR_WIDTH_KEY + ? String(SIDEBAR_WIDTH_STORAGE_MAX + 50) + : null, + ), + setItem: vi.fn(), + }, + writable: true, + configurable: true, + }); + // @ts-expect-error -- query string busts module cache + const maxMod = await import("./ui.svelte.js?sidebarWidthStoredMax"); + + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: vi.fn((key: string) => + key === SIDEBAR_WIDTH_KEY ? "300" : null, + ), + setItem: vi.fn(), + }, + writable: true, + configurable: true, + }); + // @ts-expect-error -- query string busts module cache + const stringMod = await import("./ui.svelte.js?sidebarWidthStoredString"); + + expect(minMod.ui.sidebarWidth).toBe(SIDEBAR_WIDTH_MIN); + expect(maxMod.ui.sidebarWidth).toBe( + SIDEBAR_WIDTH_STORAGE_MAX, + ); + expect(stringMod.ui.sidebarWidth).toBe(300); + } finally { + Object.defineProperty(globalThis, "localStorage", { + value: original, + writable: true, + configurable: true, + }); + } + }); + + it("persists clamped widths through setSidebarWidth", async () => { + const original = globalThis.localStorage; + const setItem = vi.fn(); + + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: vi.fn(() => null), + setItem, + }, + writable: true, + configurable: true, + }); + + try { + // @ts-expect-error -- query string busts module cache + const mod = await import("./ui.svelte.js?sidebarWidthPersist"); + setItem.mockClear(); + + mod.ui.setSidebarWidth(SIDEBAR_WIDTH_MIN - 10); + await tick(); + expect(mod.ui.sidebarWidth).toBe(SIDEBAR_WIDTH_MIN); + expect(setItem).toHaveBeenCalledTimes(1); + expect(setItem).toHaveBeenLastCalledWith( + SIDEBAR_WIDTH_KEY, + String(SIDEBAR_WIDTH_MIN), + ); + + setItem.mockClear(); + mod.ui.setSidebarWidth(SIDEBAR_WIDTH_STORAGE_MAX + 10); + await tick(); + expect(mod.ui.sidebarWidth).toBe( + SIDEBAR_WIDTH_STORAGE_MAX, + ); + expect(setItem).toHaveBeenCalledTimes(1); + expect(setItem).toHaveBeenLastCalledWith( + SIDEBAR_WIDTH_KEY, + String(SIDEBAR_WIDTH_STORAGE_MAX), + ); + } finally { + Object.defineProperty(globalThis, "localStorage", { + value: original, + writable: true, + configurable: true, + }); + } + }); + + it("survives when localStorage.getItem is unavailable", async () => { + const original = globalThis.localStorage; + + Object.defineProperty(globalThis, "localStorage", { + value: { + setItem: vi.fn(), + }, + writable: true, + configurable: true, + }); + + try { + // @ts-expect-error -- query string busts module cache + const mod = await import("./ui.svelte.js?sidebarWidthNoGetItem"); + expect(mod.ui.sidebarWidth).toBe(SIDEBAR_WIDTH_DEFAULT); + } finally { + Object.defineProperty(globalThis, "localStorage", { + value: original, + writable: true, + configurable: true, + }); + } + }); + + it("survives when localStorage.setItem is unavailable", async () => { + const original = globalThis.localStorage; + + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: vi.fn(() => String(SIDEBAR_WIDTH_DEFAULT + 10)), + }, + writable: true, + configurable: true, + }); + + try { + // @ts-expect-error -- query string busts module cache + const mod = await import("./ui.svelte.js?sidebarWidthNoSetItem"); + expect(mod.ui.sidebarWidth).toBe(SIDEBAR_WIDTH_DEFAULT + 10); + expect(() => + mod.ui.setSidebarWidth(SIDEBAR_WIDTH_DEFAULT + 20), + ).not.toThrow(); + expect(mod.ui.sidebarWidth).toBe(SIDEBAR_WIDTH_DEFAULT + 20); + } finally { + Object.defineProperty(globalThis, "localStorage", { + value: original, + writable: true, + configurable: true, + }); + } + }); + + it("survives when localStorage is null", async () => { + const original = globalThis.localStorage; + + Object.defineProperty(globalThis, "localStorage", { + value: null, + writable: true, + configurable: true, + }); + + try { + // @ts-expect-error -- query string busts module cache + const mod = await import("./ui.svelte.js?sidebarWidthNullStorage"); + expect(mod.ui.sidebarWidth).toBe(SIDEBAR_WIDTH_DEFAULT); + expect(() => + mod.ui.setSidebarWidth(SIDEBAR_WIDTH_DEFAULT + 15), + ).not.toThrow(); + } finally { + Object.defineProperty(globalThis, "localStorage", { + value: original, + writable: true, + configurable: true, + }); + } + }); + + it("survives when localStorage is undefined", async () => { + const original = globalThis.localStorage; + // @ts-expect-error -- deliberately removing localStorage + delete globalThis.localStorage; + + try { + // @ts-expect-error -- query string busts module cache + const mod = await import("./ui.svelte.js?sidebarWidthNoStorage"); + expect(mod.ui.sidebarWidth).toBe(SIDEBAR_WIDTH_DEFAULT); + expect(() => + mod.ui.setSidebarWidth(SIDEBAR_WIDTH_DEFAULT + 25), + ).not.toThrow(); + } finally { + Object.defineProperty(globalThis, "localStorage", { + value: original, + writable: true, + configurable: true, + }); + } + }); + }); + describe("postMessage theme control", () => { it("should change theme on valid theme:set message", () => { ui.theme = "light"; @@ -373,19 +615,50 @@ describe("UIStore", () => { }); it("should persist transcript mode changes", async () => { - ui.setTranscriptMode("focused"); - await Promise.resolve(); - expect(localStorage.getItem("agentsview-transcript-mode")).toBe( - "focused", - ); + const original = globalThis.localStorage; + const setItem = vi.fn(); + const getItem = vi.fn(() => null); + + Object.defineProperty(globalThis, "localStorage", { + value: { getItem, setItem }, + writable: true, + configurable: true, + }); + + try { + // @ts-expect-error -- cache bust for fresh UIStore + const mod = await import("./ui.svelte.js?persistTranscriptMode"); + setItem.mockClear(); + mod.ui.setTranscriptMode("focused"); + await Promise.resolve(); + expect(setItem).toHaveBeenLastCalledWith( + "agentsview-transcript-mode", + "focused", + ); + } finally { + Object.defineProperty(globalThis, "localStorage", { + value: original, + writable: true, + configurable: true, + }); + } }); it("should fall back to normal for invalid stored transcript mode", async () => { const original = globalThis.localStorage; - localStorage.setItem( - "agentsview-transcript-mode", - "detailed", - ); + + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: vi.fn((key: string) => + key === "agentsview-transcript-mode" + ? "detailed" + : null, + ), + setItem: vi.fn(), + }, + writable: true, + configurable: true, + }); try { // @ts-expect-error -- cache bust for fresh UIStore const mod = await import("./ui.svelte.js?badTranscriptMode"); From c4c2a0d8c23ef6f3a7d55db7d0bdcfae6b9cdd3d Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Tue, 24 Mar 2026 15:07:13 -0700 Subject: [PATCH 3/5] feat(ui): add resizable session sidebar --- .../layout/ThreeColumnLayout.svelte | 351 ++++++++- .../layout/ThreeColumnLayout.test.ts | 700 ++++++++++++++++++ 2 files changed, 1043 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/components/layout/ThreeColumnLayout.test.ts diff --git a/frontend/src/lib/components/layout/ThreeColumnLayout.svelte b/frontend/src/lib/components/layout/ThreeColumnLayout.svelte index 5c3acd6b..847e7e96 100644 --- a/frontend/src/lib/components/layout/ThreeColumnLayout.svelte +++ b/frontend/src/lib/components/layout/ThreeColumnLayout.svelte @@ -1,5 +1,13 @@ -
- {#if ui.sidebarOpen} - + + +
+ {#if ui.isMobileViewport && ui.sidebarOpen} + {/if} -