Merge pull request #449 from Molecule-AI/fix/issue-425-sidepanel-width-persist

fix(canvas): persist SidePanel width to localStorage (closes #425)
This commit is contained in:
Hongming Wang 2026-04-16 03:49:05 -07:00 committed by GitHub
commit 520c993baa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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);
});
});