feat(shell): resizable stacks sidebar

Add a desktop resize boundary for the stacks sidebar. When the sidebar
mode preference is resizable, the shell wraps the sidebar in a width-
owned pane with a draggable separator: pointer drag updates the pane
live and persists one sanitized width on release; pointercancel, lost
pointer capture, and unmount clean up without committing. The separator
is keyboard accessible (arrows, Home, End) and exposes role=separator
ARIA values that track the effective bounds.

The effective width clamps to the viewport through a ResizeObserver on
the shell flex row (sidebar plus workspace), reserving a minimum
workspace width. Narrowing the window shrinks the pane live without
rewriting the stored preference; widening restores the preferred width.
Fixed mode (the default) renders the original shell DOM unchanged, and
mobile is untouched.
This commit is contained in:
Anso
2026-09-07 22:10:24 -04:00
parent 43427b1b00
commit 2ad66dbc5a
4 changed files with 468 additions and 3 deletions
+18 -2
View File
@@ -44,6 +44,8 @@ import { useAuth } from '@/context/AuthContext';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { StackSidebar } from '@/components/sidebar/StackSidebar';
import { SidebarResizePane } from '@/components/sidebar/SidebarResizePane';
import { useSidebarLayout } from '@/hooks/use-sidebar-layout';
import type { StackRowStatus } from '@/components/sidebar/stack-status-utils';
import { useSidebarActivitySummary } from '@/components/sidebar/useSidebarActivitySummary';
import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun';
@@ -462,6 +464,8 @@ export default function EditorLayout() {
// full-screen stack detail. `mobileView` is explicit state, decoupled from
// `activeView`, so 'dashboard' still maps to HomeDashboard everywhere.
const isMobile = useIsMobile();
const { sidebarMode, sidebarWidth, setSidebarWidth } = useSidebarLayout();
const commitSidebarWidth = useCallback((width: number) => setSidebarWidth(width), [setSidebarWidth]);
const [mobileView, setMobileView] = useState<MobileView>('list');
const [mobileSettingsSection, setMobileSettingsSection] = useState<SectionId | null>(null);
// Optimistically flip to the detail surface the instant a row is tapped,
@@ -1039,9 +1043,21 @@ export default function EditorLayout() {
onClearSelection={clearSelection}
onBulkAction={handleBulkAction}
showUpdatesChip={sidebarIndicators}
fluid={sidebarMode === 'resizable'}
/>
);
// Desktop resizable branch: the pane owns the width and adds the
// separator; Fixed keeps the original shell DOM untouched.
const sidebarSlotEl = !isMobile && sidebarMode === 'resizable' ? (
<SidebarResizePane
sidebarWidth={sidebarWidth}
onCommitWidth={commitSidebarWidth}
>
{sidebarEl}
</SidebarResizePane>
) : sidebarEl;
const notificationsEl = (
<NotificationPanel
notifications={notifications}
@@ -1382,9 +1398,9 @@ export default function EditorLayout() {
<div className="flex h-screen w-screen overflow-hidden app-canvas text-foreground">
{commandPaletteEl}
{/* Left Sidebar (Stacks) */}
{sidebarEl}
{sidebarSlotEl}
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
{topBarEl}
{/* Main Workspace */}
{workspaceEl}
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { SIDEBAR_WIDTH, sanitizeSidebarWidth } from '@/hooks/use-sidebar-layout';
/**
* Desktop sidebar resize boundary. Wraps the stacks sidebar in a width-owned
* pane and renders the draggable separator beside it. This component is the
* single resize owner: EditorLayout never holds drag state.
*
* Three widths stay separate: the preferred width (the persisted preference,
* never mutated by the viewport), the in-flight live drag width, and the
* effective width actually applied to the pane (preferred clamped to what the
* viewport allows). Narrowing the window shrinks the pane live but does not
* rewrite the preference; widening restores the preferred width.
*/
/** Workspace px reserved to the right of the sidebar before clamping bites. */
const MIN_WORKSPACE = 560;
/** Separator hit area in px between sidebar and workspace. */
const HANDLE_FOOTPRINT = 12;
/** Keyboard step per arrow press, in px. */
const KEY_STEP = 16;
interface SidebarResizePaneProps {
sidebarWidth: number;
onCommitWidth: (width: number) => void;
children: ReactNode;
}
export function SidebarResizePane({ sidebarWidth, onCommitWidth, children }: SidebarResizePaneProps) {
const paneId = useId();
const paneRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [dragging, setDragging] = useState(false);
const dragRef = useRef<{
pointerId: number;
startX: number;
startWidth: number;
lastWidth: number;
committed: boolean;
} | null>(null);
// The pane's flex row (its parent: sidebar pane + separator + workspace)
// is the viewport proxy; measuring it avoids window.innerWidth.
useEffect(() => {
const el = paneRef.current?.parentElement;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.width > 0) setContainerWidth(rect.width);
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width;
if (width !== undefined && width > 0) setContainerWidth(width);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// effectiveMax floors the viewport-derived bound to an integer; at zero
// (before the first measurement) the unclamped bounds apply.
const effectiveMax = containerWidth > 0
? Math.floor(Math.max(SIDEBAR_WIDTH.min, Math.min(SIDEBAR_WIDTH.max, containerWidth - MIN_WORKSPACE - HANDLE_FOOTPRINT)))
: SIDEBAR_WIDTH.max;
const effectiveMin = SIDEBAR_WIDTH.min;
const effectiveWidth = Math.min(
effectiveMax,
Math.max(effectiveMin, sanitizeSidebarWidth(sidebarWidth)),
);
const applyPaneWidth = useCallback((width: number): void => {
if (paneRef.current) paneRef.current.style.width = `${width}px`;
}, []);
// Centralized, idempotent teardown for every termination path. A trailing
// lostpointercapture after a committed pointerup is a no-op because the
// committed pointerup cleared dragRef first.
const endDrag = useCallback((commit: boolean): void => {
const drag = dragRef.current;
dragRef.current = null;
if (drag !== null && commit && !drag.committed) {
drag.committed = true;
onCommitWidth(Math.round(drag.lastWidth));
}
try {
if (drag !== null) paneRef.current?.releasePointerCapture(drag.pointerId);
} catch {
/* capture may already be released */
}
document.body.style.cursor = '';
document.body.style.userSelect = '';
setDragging(false);
}, [onCommitWidth]);
// Unmount mid-drag still cleans the body styles.
useEffect(() => () => endDrag(false), [endDrag]);
// The pane is declaratively owned except mid-drag, when pointermove writes
// the style directly (no React state, so only the boundary moves).
useEffect(() => {
if (!dragging && paneRef.current) paneRef.current.style.width = `${effectiveWidth}px`;
}, [effectiveWidth, dragging]);
const onSeparatorPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
event.preventDefault();
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: effectiveWidth,
lastWidth: effectiveWidth,
committed: false,
};
setDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [effectiveWidth]);
const onSeparatorPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (drag === null || drag.pointerId !== event.pointerId) return;
const raw = drag.startWidth + (event.clientX - drag.startX);
const next = Math.min(effectiveMax, Math.max(effectiveMin, raw));
drag.lastWidth = next;
applyPaneWidth(next);
}, [effectiveMax, effectiveMin, applyPaneWidth]);
const onSeparatorPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (drag === null || drag.pointerId !== event.pointerId) return;
// Snapshot and mark committed BEFORE releasing capture so the trailing
// lostpointercapture finds no drag and cleans up without a second commit.
drag.committed = true;
onCommitWidth(Math.round(drag.lastWidth));
endDrag(false);
}, [endDrag, onCommitWidth]);
const onSeparatorKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
let next: number | null = null;
if (event.key === 'Home') {
next = effectiveMin;
} else if (event.key === 'End') {
next = effectiveMax;
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const delta = event.key === 'ArrowRight' ? KEY_STEP : -KEY_STEP;
next = Math.min(effectiveMax, Math.max(effectiveMin, effectiveWidth + delta));
}
if (next === null) return;
event.preventDefault();
applyPaneWidth(next);
onCommitWidth(next);
}, [effectiveMax, effectiveMin, effectiveWidth, applyPaneWidth, onCommitWidth]);
return (
<>
<div
ref={paneRef}
id={paneId}
data-testid="sidebar-resize-pane"
className="h-full shrink-0 min-w-0 overflow-hidden"
style={{ width: effectiveWidth }}
>
{children}
</div>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize stacks sidebar"
aria-controls={paneId}
aria-valuenow={effectiveWidth}
aria-valuemin={effectiveMin}
aria-valuemax={effectiveMax}
aria-valuetext={`${effectiveWidth} pixels`}
tabIndex={0}
data-testid="sidebar-resize-separator"
className="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-glass-border outline-none hover:bg-brand focus-visible:bg-brand focus-visible:ring-1 focus-visible:ring-brand/50"
onPointerDown={onSeparatorPointerDown}
onPointerMove={onSeparatorPointerMove}
onPointerUp={onSeparatorPointerUp}
onPointerCancel={() => endDrag(false)}
onLostPointerCapture={() => endDrag(false)}
onKeyDown={onSeparatorKeyDown}
>
<span className="absolute inset-y-0 left-0 -right-1.5 z-10" aria-hidden />
</div>
</>
);
}
@@ -41,6 +41,9 @@ export interface StackSidebarProps {
filterStale?: boolean;
/** False while status evidence is not authoritative; disables bulk buttons. */
actionsReady?: boolean;
/** Fill the parent pane instead of the fixed desktop width (the resizable
* shell pane owns the width); mobile classes are unaffected. */
fluid?: boolean;
}
export function StackSidebar(props: StackSidebarProps) {
@@ -52,6 +55,7 @@ export function StackSidebar(props: StackSidebarProps) {
showUpdatesChip = true,
filterStale = false,
actionsReady = false,
fluid = false,
} = props;
const [filtersVisible, setFiltersVisible] = useState(() => {
@@ -72,7 +76,7 @@ export function StackSidebar(props: StackSidebarProps) {
return (
<div
data-sn-chrome="sidebar"
className="w-64 max-md:w-full max-md:flex-1 max-md:min-h-0 max-md:border-r-0 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col"
className={`${fluid ? 'w-full' : 'w-64'} max-md:w-full max-md:flex-1 max-md:min-h-0 max-md:border-r-0 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col`}
>
{/* On mobile the status masthead leads (it carries the node switcher as
its kicker chip), so the in-sidebar brand and node rows are redundant
@@ -0,0 +1,259 @@
/**
* Coverage for SidebarResizePane, the desktop sidebar resize boundary.
*
* The pane owns the width: dragging writes the pane style directly (no React
* state per move) and only a pointerup commits a preference write; every
* cancellation path (pointercancel, lost capture, unmount) cleans the body
* styles without committing. A ResizeObserver on the shell flex row drives
* the effective bounds so a narrow window clamps live without rewriting the
* stored width.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SidebarResizePane } from '../SidebarResizePane';
import { SIDEBAR_WIDTH, SIDEBAR_WIDTH_KEY, useSidebarLayout } from '@/hooks/use-sidebar-layout';
import { subscribeToPreferenceWrites } from '@/lib/preferences/preferenceEvents';
const OriginalResizeObserver = globalThis.ResizeObserver;
const observed: { el: Element; cb: ResizeObserverCallback }[] = [];
let shellRowWidth = 0;
function shellRowRect(width: number): DOMRect {
return {
width,
height: 800,
top: 0,
left: 0,
bottom: 800,
right: width,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
function notifyShellRowResize(): void {
for (const { el, cb } of [...observed]) {
cb(
[{
target: el,
contentRect: el.getBoundingClientRect(),
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
} as unknown as ResizeObserverEntry],
{} as ResizeObserver,
);
}
}
function pane(): HTMLElement {
return screen.getByTestId('sidebar-resize-pane');
}
function separator(): HTMLElement {
return screen.getByRole('separator', { name: 'Resize stacks sidebar' });
}
function dragSeparator(fromX: number, toX: number): void {
const sep = separator();
fireEvent.pointerDown(sep, { pointerId: 1, clientX: fromX });
fireEvent.pointerMove(sep, { pointerId: 1, clientX: toX });
fireEvent.pointerUp(sep, { pointerId: 1, clientX: toX });
}
// Harness mirroring the real wiring: width state comes from the shared
// preference hook, so a commit lands in localStorage and notifies the bus
// exactly like the shell does.
function Harness({ onCommit }: { onCommit?: (w: number) => void }) {
const { sidebarWidth, setSidebarWidth } = useSidebarLayout();
return (
<div style={{ display: 'flex', width: '100%' }}>
<SidebarResizePane
sidebarWidth={sidebarWidth}
onCommitWidth={(w) => { setSidebarWidth(w); onCommit?.(w); }}
>
<div data-testid="sidebar-content">sidebar</div>
</SidebarResizePane>
<div data-testid="workspace">workspace</div>
</div>
);
}
function setup(onCommit?: (w: number) => void) {
return render(<Harness onCommit={onCommit} />);
}
describe('SidebarResizePane', () => {
beforeEach(() => {
localStorage.clear();
observed.length = 0;
shellRowWidth = 1200;
const origRect = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
// The pane's parent is the shell flex row (pane + separator + workspace).
if (this.querySelector?.('[data-testid="workspace"]')) return shellRowRect(shellRowWidth);
return origRect.call(this);
});
globalThis.ResizeObserver = class MockShellRowResizeObserver {
cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this.cb = cb;
}
observe(el: Element) {
observed.push({ el, cb: this.cb });
this.cb(
[{
target: el,
contentRect: el.getBoundingClientRect(),
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
} as unknown as ResizeObserverEntry],
this as unknown as ResizeObserver,
);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => {
globalThis.ResizeObserver = OriginalResizeObserver;
vi.restoreAllMocks();
});
it('applies the preferred width on mount and renders the separator with live ARIA bounds', async () => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, '320');
setup();
await waitFor(() => {
expect(pane().style.width).toBe('320px');
expect(separator()).toHaveAttribute('aria-valuenow', '320');
expect(separator()).toHaveAttribute('aria-valuemin', String(SIDEBAR_WIDTH.min));
expect(separator()).toHaveAttribute('aria-valuemax', String(SIDEBAR_WIDTH.max));
});
});
it('clamps the live drag to the viewport bound without rewriting storage', async () => {
shellRowWidth = 900; // effectiveMax = 900 - 560 - 12 = 328
const commits: number[] = [];
setup((w) => commits.push(w));
await waitFor(() => expect(separator()).toHaveAttribute('aria-valuemax', '328'));
dragSeparator(300, 900);
expect(pane().style.width).toBe('328px');
expect(commits).toEqual([328]);
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('328');
});
it('emits exactly one sanitized integer commit per drag', async () => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, '280');
const commits: number[] = [];
setup((w) => commits.push(w));
const sep = separator();
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 });
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 321.6 }); // fractional pointer delta
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 332.2 });
fireEvent.pointerUp(sep, { pointerId: 1, clientX: 332.2 });
expect(commits).toEqual([312]);
expect(Number.isInteger(commits[0])).toBe(true);
expect(pane().style.width).toBe('312px');
});
it('writes pane style per move without any preference write until release', async () => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, '280');
const notify = vi.fn();
const unsub = subscribeToPreferenceWrites(notify);
const commits: number[] = [];
setup((w) => commits.push(w));
const sep = separator();
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 });
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 380 });
expect(pane().style.width).toBe('360px');
expect(commits).toEqual([]);
expect(notify).not.toHaveBeenCalled();
fireEvent.pointerUp(sep, { pointerId: 1, clientX: 380 });
expect(commits).toEqual([360]);
expect(notify).toHaveBeenCalledTimes(1);
unsub();
});
it('tears down on pointercancel without committing and restores the effective width', async () => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, '280');
const commits: number[] = [];
setup((w) => commits.push(w));
const sep = separator();
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 });
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 380 });
expect(document.body.style.cursor).toBe('col-resize');
expect(document.body.style.userSelect).toBe('none');
fireEvent.pointerCancel(sep, { pointerId: 1, clientX: 0 });
expect(commits).toEqual([]);
expect(pane().style.width).toBe('280px');
expect(document.body.style.cursor).toBe('');
expect(document.body.style.userSelect).toBe('');
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 500 });
fireEvent.pointerUp(sep, { pointerId: 1, clientX: 500 });
expect(commits).toEqual([]);
});
it('finishes on lostpointercapture without committing and ignores a trailing one after pointerup', async () => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, '280');
const commits: number[] = [];
setup((w) => commits.push(w));
const sep = separator();
// Unexpected capture loss mid-drag: no commit.
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 });
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 380 });
fireEvent.lostPointerCapture(sep, { pointerId: 1, clientX: 0 });
expect(commits).toEqual([]);
expect(pane().style.width).toBe('280px');
expect(document.body.style.cursor).toBe('');
// Committed pointerup, then the browser's trailing lostpointercapture.
dragSeparator(300, 340);
fireEvent.lostPointerCapture(sep, { pointerId: 1, clientX: 0 });
expect(commits).toEqual([320]);
expect(pane().style.width).toBe('320px');
});
it('clears body resize styles if the pane unmounts mid-drag', async () => {
const { unmount } = setup();
fireEvent.pointerDown(separator(), { pointerId: 1, clientX: 300 });
expect(document.body.style.cursor).toBe('col-resize');
unmount();
expect(document.body.style.cursor).toBe('');
expect(document.body.style.userSelect).toBe('');
});
it('shrinks the live pane on a narrow shell without rewriting storage, then restores on widen', async () => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, '400');
shellRowWidth = 900; // effectiveMax = 328
const commits: number[] = [];
setup((w) => commits.push(w));
await waitFor(() => expect(pane().style.width).toBe('328px'));
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('400');
shellRowWidth = 1200;
act(() => { notifyShellRowResize(); });
await waitFor(() => expect(pane().style.width).toBe('400px'));
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('400');
expect(commits).toEqual([]);
});
it('commits Home/End and arrow-key widths and updates ARIA live', async () => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, '300');
const user = userEvent.setup();
const commits: number[] = [];
shellRowWidth = 1200;
setup((w) => commits.push(w));
separator().focus();
await user.keyboard('{ArrowRight}');
expect(commits).toEqual([316]);
expect(separator()).toHaveAttribute('aria-valuenow', '316');
await user.keyboard('{Home}');
expect(pane().style.width).toBe(`${SIDEBAR_WIDTH.min}px`);
await user.keyboard('{End}');
expect(pane().style.width).toBe(`${SIDEBAR_WIDTH.max}px`);
expect(separator()).toHaveAttribute('aria-valuemax', String(SIDEBAR_WIDTH.max));
expect(commits).toEqual([316, SIDEBAR_WIDTH.min, SIDEBAR_WIDTH.max]);
});
});