fix(canvas): re-centre Toolbar on canvas area when SidePanel is open

When a workspace is selected the SidePanel (fixed, right-0, z-50)
opens from the right edge and covers the right third of the
viewport. The Toolbar at the top was positioned
`fixed top-3 left-1/2 -translate-x-1/2 z-20` — centred on the full
viewport, not the remaining canvas area. Consequence: the right half
of the Toolbar (Audit / Search / Help / Settings) was hidden behind
the panel as soon as the user clicked any workspace.

Fix: publish the live SidePanel width to the canvas store and read
it in Toolbar. When a node is selected, shift the Toolbar LEFT by
`sidePanelWidth / 2` so its centre lines up with the middle of the
remaining canvas area. Animated via a 200 ms `transition-[margin-left]`
to match the SidePanel's own slide-in easing.

- `store/canvas.ts` — added `sidePanelWidth` + `setSidePanelWidth`.
  Default 480 (matches SIDEPANEL_DEFAULT_WIDTH).
- `SidePanel.tsx` — calls `setSidePanelWidth(width)` on every width
  change so the store stays in sync with localStorage.
- `Toolbar.tsx` — reads `sidePanelWidth`, applies a negative
  `marginLeft` style when `selectedNodeId` is non-null.
- `SidePanel.tabs.test.tsx` — added `setSidePanelWidth: vi.fn()` to
  the mocked store state so SidePanel's new useEffect has a callable
  to invoke. 18 previously-passing tests now pass again.

No visual regression when no workspace is selected — the toolbar
stays in its original centred position. SaaS canvas unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 15:57:12 -07:00
parent a0ac72f725
commit 5eb5e38c59
4 changed files with 36 additions and 2 deletions

View File

@ -46,11 +46,15 @@ export function SidePanel() {
const panelTab = useCanvasStore((s) => s.panelTab);
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
const selectNode = useCanvasStore((s) => s.selectNode);
const setSidePanelWidth = useCanvasStore((s) => s.setSidePanelWidth);
const node = useCanvasStore((s) =>
s.nodes.find((n) => n.id === s.selectedNodeId)
);
// Resizable panel width — persisted across node selections via localStorage
// Resizable panel width — persisted across node selections via localStorage.
// Also published to the canvas store on every change so the centered
// Toolbar can re-centre itself on the remaining canvas area (avoids the
// Audit / Search / Settings buttons hiding under the panel).
const [width, setWidth] = useState<number>(() => {
if (typeof window === "undefined") return SIDEPANEL_DEFAULT_WIDTH;
const saved = localStorage.getItem(SIDEPANEL_WIDTH_KEY);
@ -59,6 +63,9 @@ export function SidePanel() {
? parsed
: SIDEPANEL_DEFAULT_WIDTH;
});
useEffect(() => {
setSidePanelWidth(width);
}, [width, setSidePanelWidth]);
const widthRef = useRef(width); // tracks live drag value for the mouseup handler
const dragging = useRef(false);
const startX = useRef(0);

View File

@ -16,6 +16,17 @@ export function Toolbar() {
const setShowA2AEdges = useCanvasStore((s) => s.setShowA2AEdges);
const selectedNodeId = useCanvasStore((s) => s.selectedNodeId);
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
const sidePanelWidth = useCanvasStore((s) => s.sidePanelWidth);
// Toolbar is fixed + centred on the viewport. When a workspace is
// selected the SidePanel (z-50, fixed right-0) opens and covers the
// right edge of the viewport — without this adjustment, the right
// half of the Toolbar (Audit / Search / Help / Settings) hides
// behind the panel. Shifting the toolbar LEFT by half the panel
// width re-centres it on the remaining canvas area.
const toolbarOffsetStyle = selectedNodeId
? { marginLeft: `-${sidePanelWidth / 2}px` }
: undefined;
const [stopping, setStopping] = useState(false);
const [restartingAll, setRestartingAll] = useState(false);
@ -116,7 +127,10 @@ export function Toolbar() {
}, []);
return (
<div className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-zinc-900/80 backdrop-blur-md border border-zinc-800/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20">
<div
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-zinc-900/80 backdrop-blur-md border border-zinc-800/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
style={toolbarOffsetStyle}
>
{/* Logo / Title */}
<div className="flex items-center gap-2 pr-3 border-r border-zinc-800/60">
<img src="/molecule-icon.png" alt="Molecule AI" className="w-5 h-5" />

View File

@ -36,6 +36,10 @@ const mockStoreState = {
panelTab: "chat",
setPanelTab: mockSetPanelTab,
selectNode: vi.fn(),
// Consumed by SidePanel's useEffect — publishes the drag-resized
// width to the store so Toolbar can re-centre itself on the
// remaining canvas area when the panel is open.
setSidePanelWidth: vi.fn(),
nodes: [
{
id: "ws-1",

View File

@ -51,6 +51,13 @@ interface CanvasState {
panelTab: PanelTab;
dragOverNodeId: string | null;
contextMenu: ContextMenuState | null;
// Live width of the SidePanel in pixels. Only meaningful when
// selectedNodeId is non-null (panel visible). The Toolbar reads this
// to stay centred on the remaining canvas area instead of the full
// viewport, so the "Audit" / "Search" / "Settings" buttons don't get
// hidden behind the panel when a workspace is selected.
sidePanelWidth: number;
setSidePanelWidth: (w: number) => void;
hydrate: (workspaces: WorkspaceData[]) => void;
applyEvent: (msg: WSMessage) => void;
onNodesChange: (changes: NodeChange<Node<WorkspaceNodeData>>[]) => void;
@ -115,6 +122,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
panelTab: "chat",
dragOverNodeId: null,
contextMenu: null,
sidePanelWidth: 480, // matches SIDEPANEL_DEFAULT_WIDTH in SidePanel.tsx
setSidePanelWidth: (w) => set({ sidePanelWidth: w }),
// Batch selection
selectedNodeIds: new Set<string>(),
toggleNodeSelection: (id) => {