fix: smooth sidebar collapse and expand

This commit is contained in:
maaz 2026-07-05 07:39:12 +05:30
parent 821cda64a8
commit 23029b2fce
4 changed files with 170 additions and 27 deletions

View File

@ -1,6 +1,6 @@
import { SidebarProvider } from "@/components/ui/sidebar";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Sidebar } from "./Sidebar";
@ -112,6 +112,8 @@ async function chooseOption(trigger: HTMLElement, optionName: string) {
}
beforeEach(() => {
window.localStorage.clear();
document.documentElement.style.removeProperty("--ao-sidebar-w");
getMock.mockReset();
getMock.mockResolvedValue({
data: {
@ -350,4 +352,36 @@ describe("Sidebar", () => {
// Padding is always reserved for the action cluster (not hover-gated)
expect(projectRow).toHaveClass("pr-[84px]");
});
it("snaps to the real collapsed rail when dragged past the resize collapse threshold", async () => {
renderSidebar();
const resizeHandle = document.querySelector(".resize-handle--right");
if (!(resizeHandle instanceof HTMLElement)) throw new Error("Resize handle not found");
expect(document.querySelector('[data-slot="sidebar"][data-state="expanded"]')).toBeInTheDocument();
fireEvent.pointerDown(resizeHandle, { clientX: 240 });
fireEvent.pointerMove(window, { clientX: 120 });
await waitFor(() => {
expect(document.querySelector('[data-slot="sidebar"][data-state="collapsed"]')).toBeInTheDocument();
});
expect(document.cookie).toContain("sidebar_state=false");
expect(window.localStorage.getItem("ao-sidebar-w")).toBe("240");
expect(document.documentElement.style.getPropertyValue("--ao-sidebar-w")).toBe("240px");
expect(document.body).not.toHaveClass("is-resizing-x");
const expandRail = document.querySelector('[data-sidebar="rail"]');
if (!(expandRail instanceof HTMLElement)) throw new Error("Sidebar rail not found");
fireEvent.pointerDown(expandRail, { clientX: 48 });
fireEvent.pointerMove(window, { clientX: 128 });
fireEvent.pointerUp(window);
await waitFor(() => {
expect(document.querySelector('[data-slot="sidebar"][data-state="expanded"]')).toBeInTheDocument();
});
expect(document.documentElement.style.getPropertyValue("--ao-sidebar-w")).toBe("280px");
expect(window.localStorage.getItem("ao-sidebar-w")).toBe("280");
});
});

View File

@ -47,6 +47,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarTrigger,
@ -75,6 +76,10 @@ const HOVER_ACTION_CLASS =
// Mirrors the daemon's display-name cap (maxDisplayNameLen) and the spawn
// `--name` flag, so inline edits never round-trip a value the API would reject.
const MAX_DISPLAY_NAME_LEN = 20;
const SIDEBAR_DEFAULT_WIDTH = 240;
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_MAX_WIDTH = 420;
const SIDEBAR_COLLAPSE_THRESHOLD = SIDEBAR_MIN_WIDTH;
type SidebarProps = {
daemonStatus: { state: string; message?: string };
@ -138,7 +143,7 @@ export function Sidebar({
}: SidebarProps) {
const selection = useSelection();
const eventsConnection = useEventsConnection();
const { state } = useSidebar();
const { state, setOpen } = useSidebar();
const isCollapsed = state === "collapsed";
const theme = useUiStore((s) => s.theme);
const toggleTheme = useUiStore((s) => s.toggleTheme);
@ -164,13 +169,20 @@ export function Sidebar({
// agent-orchestrator's sidebar resize: drag the right edge (200-420px,
// persisted), double-click to reset to 240px. Drives --ao-sidebar-w on :root,
// which the provider forwards into shadcn's --sidebar-width.
const { onPointerDown: onResizePointerDown, onDoubleClick: onResizeDoubleClick } = useResizable({
const {
onPointerDown: onResizePointerDown,
onCollapsedPointerDown: onCollapsedResizePointerDown,
onDoubleClick: onResizeDoubleClick,
} = useResizable({
cssVar: "--ao-sidebar-w",
storageKey: "ao-sidebar-w",
defaultWidth: 240,
min: 200,
max: 420,
defaultWidth: SIDEBAR_DEFAULT_WIDTH,
min: SIDEBAR_MIN_WIDTH,
max: SIDEBAR_MAX_WIDTH,
edge: "right",
collapseBelow: SIDEBAR_COLLAPSE_THRESHOLD,
onCollapse: () => setOpen(false),
onExpand: () => setOpen(true),
});
return (
@ -184,7 +196,7 @@ export function Sidebar({
<SidebarHeader className="gap-0 p-0 pl-2.5 pr-[7px] pt-3.5 group-data-[collapsible=icon]:px-1.5">
{/* Brand (project-sidebar__brand); in the icon rail it becomes the old
36px board button wrapping the 22px accent mark. */}
<div className="flex shrink-0 items-center gap-2.5 px-2 pb-[18px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:pb-2">
<div className="flex shrink-0 items-center gap-2.5 px-2 pb-[18px] group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:gap-1 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:pb-2">
<Tooltip>
<TooltipTrigger asChild>
<button
@ -206,6 +218,17 @@ export function Sidebar({
Orchestrator board
</TooltipContent>
</Tooltip>
{!isMac && (
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger
aria-label="Expand sidebar"
className="hidden size-9 shrink-0 rounded-lg text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:grid [&_svg]:size-4"
/>
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar · B</TooltipContent>
</Tooltip>
)}
<span className="min-w-0 flex-1 truncate text-[14px] font-bold tracking-[-0.015em] text-foreground group-data-[collapsible=icon]:hidden">
Agent Orchestrator
</span>
@ -224,7 +247,10 @@ export function Sidebar({
{!isMac && (
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger className="size-[18px] shrink-0 rounded-[4px] p-0 text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:hidden [&_svg]:size-[15px]" />
<SidebarTrigger
aria-label="Collapse sidebar"
className="size-[18px] shrink-0 rounded-[4px] p-0 text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:hidden [&_svg]:size-[15px]"
/>
</TooltipTrigger>
<TooltipContent>Collapse sidebar · B</TooltipContent>
</Tooltip>
@ -280,8 +306,8 @@ export function Sidebar({
(flex-1) with a uniform 7px footer inset on all sides (reference uses
12px top, 0 bottom, content-hugging button). The icon rail keeps the
icon-only settings action plus expand toggle (off macOS). */}
<SidebarFooter className="mt-auto gap-0 border-t border-border p-[7px] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5 group-data-[collapsible=icon]:pb-0 group-data-[collapsible=icon]:pt-2">
<div className="relative flex w-full items-center group-data-[collapsible=icon]:hidden">
<SidebarFooter className="relative mt-auto min-h-[51px] gap-0 overflow-hidden border-t border-border p-[7px] transition-[padding] duration-200 ease-linear group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5">
<div className="relative flex min-h-[37px] w-full min-w-[186px] items-center transition-[opacity,transform] duration-150 ease-out group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-translate-x-2 group-data-[collapsible=icon]:opacity-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@ -341,7 +367,7 @@ export function Sidebar({
</TooltipContent>
</Tooltip>
</div>
<div className="hidden flex-col items-center gap-1 pb-3.5 group-data-[collapsible=icon]:flex">
<div className="pointer-events-none absolute inset-x-1.5 top-[7px] flex min-h-[37px] flex-col items-center justify-center gap-1 opacity-0 transition-opacity duration-150 ease-out group-data-[collapsible=icon]:pointer-events-auto group-data-[collapsible=icon]:opacity-100">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@ -385,14 +411,6 @@ export function Sidebar({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!isMac && (
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger className="size-9 rounded-lg text-passive hover:bg-interactive-hover hover:text-foreground [&_svg]:size-4" />
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar · B</TooltipContent>
</Tooltip>
)}
</div>
</SidebarFooter>
@ -402,6 +420,12 @@ export function Sidebar({
onDoubleClick={onResizeDoubleClick}
style={noDragStyle}
/>
<SidebarRail
aria-label="Expand sidebar"
className="group-data-[state=expanded]:hidden hover:after:bg-transparent"
onClick={() => setOpen(true)}
onPointerDown={onCollapsedResizePointerDown}
/>
</SidebarRoot>
);
}

View File

@ -265,7 +265,6 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",

View File

@ -14,6 +14,14 @@ interface UseResizableOptions {
* handle) grows with leftward drag.
*/
edge: "left" | "right";
/** Optional raw drag width below which the owner should collapse. */
collapseBelow?: number;
/** Called once when a drag crosses collapseBelow. */
onCollapse?: () => void;
/** Called once when a collapsed rail drag should reopen the owner. */
onExpand?: () => void;
/** Pointer movement needed before a collapsed rail drag expands. */
expandDragThreshold?: number;
}
/**
@ -22,8 +30,21 @@ interface UseResizableOptions {
* on :root (so the consuming layout reads it with `width: var(--cssVar, default)`),
* avoiding any inline `style=`.
*/
export function useResizable({ cssVar, storageKey, defaultWidth, min, max, edge }: UseResizableOptions) {
export function useResizable({
cssVar,
storageKey,
defaultWidth,
min,
max,
edge,
collapseBelow,
onCollapse,
onExpand,
expandDragThreshold = 8,
}: UseResizableOptions) {
const widthRef = useRef(defaultWidth);
const frameRef = useRef<number | null>(null);
const pendingWidthRef = useRef<number | null>(null);
const apply = useCallback(
(next: number) => {
@ -34,11 +55,36 @@ export function useResizable({ cssVar, storageKey, defaultWidth, min, max, edge
[cssVar, max, min],
);
const applyOnFrame = useCallback(
(next: number) => {
pendingWidthRef.current = next;
if (frameRef.current !== null) return;
frameRef.current = window.requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingWidthRef.current;
pendingWidthRef.current = null;
if (pending !== null) apply(pending);
});
},
[apply],
);
const flushPending = useCallback(() => {
if (frameRef.current !== null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
const pending = pendingWidthRef.current;
pendingWidthRef.current = null;
if (pending !== null) apply(pending);
}, [apply]);
// Restore persisted width on mount.
useEffect(() => {
const saved = Number(window.localStorage.getItem(storageKey));
apply(Number.isFinite(saved) && saved > 0 ? saved : defaultWidth);
return () => {
if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current);
document.documentElement.style.removeProperty(cssVar);
};
}, [apply, cssVar, defaultWidth, storageKey]);
@ -49,21 +95,61 @@ export function useResizable({ cssVar, storageKey, defaultWidth, min, max, edge
const startX = event.clientX;
const startWidth = widthRef.current;
const sign = edge === "right" ? 1 : -1;
let collapsed = false;
document.body.classList.add("is-resizing-x");
const onMove = (e: PointerEvent) => {
apply(startWidth + sign * (e.clientX - startX));
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
flushPending();
document.body.classList.remove("is-resizing-x");
window.localStorage.setItem(storageKey, String(widthRef.current));
if (!collapsed) window.localStorage.setItem(storageKey, String(widthRef.current));
};
const onMove = (e: PointerEvent) => {
const nextWidth = startWidth + sign * (e.clientX - startX);
if (collapseBelow !== undefined && onCollapse && nextWidth <= collapseBelow) {
collapsed = true;
flushPending();
window.localStorage.setItem(storageKey, String(Math.min(max, Math.max(min, startWidth))));
onUp();
onCollapse();
return;
}
applyOnFrame(nextWidth);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
},
[apply, edge, storageKey],
[applyOnFrame, collapseBelow, edge, flushPending, max, min, onCollapse, storageKey],
);
const onCollapsedPointerDown = useCallback(
(event: React.PointerEvent<HTMLElement>) => {
const startX = event.clientX;
const sign = edge === "right" ? 1 : -1;
let expanded = false;
document.body.classList.add("is-resizing-x");
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
flushPending();
document.body.classList.remove("is-resizing-x");
if (expanded) window.localStorage.setItem(storageKey, String(widthRef.current));
};
const onMove = (e: PointerEvent) => {
const delta = sign * (e.clientX - startX);
if (delta < expandDragThreshold) return;
if (!expanded) {
expanded = true;
onExpand?.();
}
applyOnFrame(min + delta);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
},
[applyOnFrame, edge, expandDragThreshold, flushPending, min, onExpand, storageKey],
);
/** Double-click the handle to reset to the default width. */
@ -72,5 +158,5 @@ export function useResizable({ cssVar, storageKey, defaultWidth, min, max, edge
window.localStorage.setItem(storageKey, String(defaultWidth));
}, [apply, defaultWidth, storageKey]);
return { onPointerDown, onDoubleClick };
return { onPointerDown, onCollapsedPointerDown, onDoubleClick };
}