diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx index e55e93ccd..708072c87 100644 --- a/frontend/src/renderer/components/Sidebar.test.tsx +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -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"); + }); }); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 09923768c..a3a8a6c03 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -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({ {/* Brand (project-sidebar__brand); in the icon rail it becomes the old 36px board button wrapping the 22px accent mark. */} -
+
-
+
@@ -385,14 +411,6 @@ export function Sidebar({ - {!isMac && ( - - - - - Expand sidebar · ⌘B - - )}
@@ -402,6 +420,12 @@ export function Sidebar({ onDoubleClick={onResizeDoubleClick} style={noDragStyle} /> + setOpen(true)} + onPointerDown={onCollapsedResizePointerDown} + /> ); } diff --git a/frontend/src/renderer/components/ui/sidebar.tsx b/frontend/src/renderer/components/ui/sidebar.tsx index b1d0676ab..1aa703cb7 100644 --- a/frontend/src/renderer/components/ui/sidebar.tsx +++ b/frontend/src/renderer/components/ui/sidebar.tsx @@ -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", diff --git a/frontend/src/renderer/hooks/useResizable.ts b/frontend/src/renderer/hooks/useResizable.ts index 4f66eb680..52d35d293 100644 --- a/frontend/src/renderer/hooks/useResizable.ts +++ b/frontend/src/renderer/hooks/useResizable.ts @@ -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(null); + const pendingWidthRef = useRef(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) => { + 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 }; }