From 966920355a09b28a0e232b07242b61137110ee0e Mon Sep 17 00:00:00 2001 From: Canvas Agent Date: Thu, 16 Apr 2026 10:40:08 +0000 Subject: [PATCH] fix(canvas): persist SidePanel width to localStorage (issue #425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Width was initialized to 480px on every render, so clicking a different workspace node (which re-mounts SidePanel) discarded any resize the user had done. Fix: - localStorage-backed useState initializer (SSR-safe typeof window guard) - Validates the stored value: must be a finite integer ≥ 320px - Persists the width in the mouseUp handler via a widthRef that stays in sync with the live drag value — avoids spamming localStorage on every pixel during the drag - Extra guard: onMouseUp bails early if not actually dragging (prevents spurious saves on unrelated window mouseup events) - Named constants replace magic numbers 480 / 320 Tests: 5 new cases in SidePanel.tabs.test.tsx — default fallback, valid saved value, too-small saved value, NaN saved value, drag-persist roundtrip. Closes #425 Co-Authored-By: Claude Sonnet 4.6 --- canvas/src/components/SidePanel.tsx | 27 +++++++-- .../__tests__/SidePanel.tabs.test.tsx | 58 ++++++++++++++++++- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index 34582ceb..2580c396 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -19,6 +19,10 @@ import { ScheduleTab } from "./tabs/ScheduleTab"; import { ChannelsTab } from "./tabs/ChannelsTab"; import { summarizeWorkspaceCapabilities } from "@/store/canvas"; +const SIDEPANEL_WIDTH_KEY = "molecule:sidepanel-width"; +const SIDEPANEL_DEFAULT_WIDTH = 480; +const SIDEPANEL_MIN_WIDTH = 320; + const TABS: { id: PanelTab; label: string; icon: string }[] = [ { id: "chat", label: "Chat", icon: "◈" }, { id: "activity", label: "Activity", icon: "⊙" }, @@ -43,11 +47,19 @@ export function SidePanel() { s.nodes.find((n) => n.id === s.selectedNodeId) ); - // Resizable panel width - const [width, setWidth] = useState(480); + // Resizable panel width — persisted across node selections via localStorage + const [width, setWidth] = useState(() => { + if (typeof window === "undefined") return SIDEPANEL_DEFAULT_WIDTH; + const saved = localStorage.getItem(SIDEPANEL_WIDTH_KEY); + const parsed = saved ? parseInt(saved, 10) : NaN; + return Number.isFinite(parsed) && parsed >= SIDEPANEL_MIN_WIDTH + ? parsed + : SIDEPANEL_DEFAULT_WIDTH; + }); + const widthRef = useRef(width); // tracks live drag value for the mouseup handler const dragging = useRef(false); const startX = useRef(0); - const startWidth = useRef(480); + const startWidth = useRef(SIDEPANEL_DEFAULT_WIDTH); const onMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -62,13 +74,20 @@ export function SidePanel() { const onMouseMove = (e: MouseEvent) => { if (!dragging.current) return; const delta = startX.current - e.clientX; - const newWidth = Math.min(Math.max(startWidth.current + delta, 320), window.innerWidth * 0.8); + const newWidth = Math.min( + Math.max(startWidth.current + delta, SIDEPANEL_MIN_WIDTH), + window.innerWidth * 0.8, + ); setWidth(newWidth); + widthRef.current = newWidth; // keep ref in sync so mouseUp can persist it }; const onMouseUp = () => { + if (!dragging.current) return; dragging.current = false; document.body.style.cursor = ""; document.body.style.userSelect = ""; + // Persist the final dragged width so it survives node re-selection + localStorage.setItem(SIDEPANEL_WIDTH_KEY, String(widthRef.current)); }; window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); diff --git a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx index a244f90b..d343ca40 100644 --- a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx +++ b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; afterEach(() => { @@ -160,3 +160,59 @@ describe("SidePanel — ARIA tablist pattern", () => { expect(mockSetPanelTab).toHaveBeenCalledWith("events"); }); }); + +describe("SidePanel — localStorage width persistence (issue #425)", () => { + const STORAGE_KEY = "molecule:sidepanel-width"; + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it("falls back to 480px when localStorage has no saved width", () => { + const { container } = render(); + const panel = container.firstChild as HTMLElement; + // The outermost div has style={{ width }} + expect(panel.style.width).toBe("480px"); + }); + + it("reads a valid saved width from localStorage on mount", () => { + localStorage.setItem(STORAGE_KEY, "600"); + const { container } = render(); + const panel = container.firstChild as HTMLElement; + expect(panel.style.width).toBe("600px"); + }); + + it("falls back to 480px when localStorage value is below minimum (320px)", () => { + localStorage.setItem(STORAGE_KEY, "200"); + const { container } = render(); + const panel = container.firstChild as HTMLElement; + expect(panel.style.width).toBe("480px"); + }); + + it("falls back to 480px when localStorage value is not a number", () => { + localStorage.setItem(STORAGE_KEY, "notanumber"); + const { container } = render(); + const panel = container.firstChild as HTMLElement; + expect(panel.style.width).toBe("480px"); + }); + + it("persists width to localStorage on mouseup after drag", () => { + localStorage.setItem(STORAGE_KEY, "600"); + render(); + // Simulate a drag: mousedown on resize handle, mousemove, mouseup + fireEvent.mouseDown(document.querySelector(".cursor-col-resize")!, { + clientX: 100, + }); + fireEvent.mouseMove(window, { clientX: 50 }); // dragged 50px left → wider + fireEvent.mouseUp(window); + // localStorage should have been updated to the new width + const saved = localStorage.getItem(STORAGE_KEY); + expect(saved).not.toBeNull(); + expect(parseInt(saved!, 10)).toBeGreaterThanOrEqual(320); + }); +});