fix(canvas): persist SidePanel width to localStorage (issue #425)

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 <noreply@anthropic.com>
This commit is contained in:
Canvas Agent 2026-04-16 10:40:08 +00:00
parent 7fc2da2146
commit 026921ae62
2 changed files with 80 additions and 5 deletions

View File

@ -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<number>(() => {
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);

View File

@ -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(<SidePanel />);
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(<SidePanel />);
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(<SidePanel />);
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(<SidePanel />);
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(<SidePanel />);
// 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);
});
});