From 9ec2df9574e07ad0d756f891099ac44fd59da577 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 7 May 2026 16:21:53 +0700 Subject: [PATCH] feat(site/src/pages/AgentsPage): resize agents sidebar (#24963) Adds a persisted, draggable left sidebar width for the agents page. The resize handle uses the same pointer-capture resize technique as the existing right panel and clamps the expanded sidebar between 240px and `min(520px, 50vw)`. Updates the agents page skeleton to read the same stored sidebar width and adds Storybook interaction coverage for resize clamping and persistence. --- .../AgentsPage/AgentsPageView.stories.tsx | 184 ++++++++++++++++++ site/src/pages/AgentsPage/AgentsPageView.tsx | 8 +- .../AgentsPage/components/AgentsSkeletons.tsx | 86 ++++---- .../Sidebar/ResizableAgentsSidebarFrame.tsx | 140 +++++++++++++ .../components/Sidebar/sidebarWidth.ts | 59 ++++++ 5 files changed, 436 insertions(+), 41 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/Sidebar/ResizableAgentsSidebarFrame.tsx create mode 100644 site/src/pages/AgentsPage/components/Sidebar/sidebarWidth.ts diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 1596b69720..053dfcda6e 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -4,6 +4,7 @@ import { type ComponentProps, useState } from "react"; import { Navigate, useOutletContext } from "react-router"; import { expect, + fireEvent, fn, screen, spyOn, @@ -38,6 +39,14 @@ import AgentSettingsSpendPage from "./AgentSettingsSpendPage"; import { type AgentsOutletContext, AgentsPageView } from "./AgentsPageView"; import type { ModelSelectorOption } from "./components/ChatElements"; import { ChatTopBar } from "./components/ChatTopBar"; +import { + clampLeftSidebarWidth, + getLeftSidebarMaxWidth, + LEFT_SIDEBAR_DEFAULT_WIDTH, + LEFT_SIDEBAR_KEYBOARD_RESIZE_STEP, + LEFT_SIDEBAR_MIN_WIDTH, + LEFT_SIDEBAR_STORAGE_KEY, +} from "./components/Sidebar/sidebarWidth"; const defaultModelConfigID = "model-config-1"; @@ -292,6 +301,7 @@ const meta: Meta = { }, args: defaultArgs, beforeEach: () => { + localStorage.removeItem(LEFT_SIDEBAR_STORAGE_KEY); spyOn(API, "getWorkspaces").mockResolvedValue({ workspaces: [], count: 0, @@ -486,6 +496,180 @@ export const WithChatList: Story = { }, }; +export const ResizableSidebar: Story = { + args: { + chatList: [ + buildChat({ + id: "chat-resize", + title: "Resizable sidebar agent", + updated_at: todayTimestamp, + }), + ], + }, + parameters: { + viewport: { defaultViewport: "ipad" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const sidebar = canvas.getByTestId("agents-sidebar-panel"); + const handle = canvas.getByRole("separator", { + name: "Resize agents sidebar", + }); + + handle.setPointerCapture = () => {}; + handle.releasePointerCapture = () => {}; + handle.hasPointerCapture = () => true; + + const sidebarWidth = () => + sidebar.style.getPropertyValue("--agents-left-sidebar-width"); + const dragSidebar = (fromX: number, toX: number) => { + fireEvent.pointerDown(handle, { clientX: fromX, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientX: toX, pointerId: 1 }); + fireEvent.pointerUp(handle, { clientX: toX, pointerId: 1 }); + }; + + const initialWidth = clampLeftSidebarWidth(LEFT_SIDEBAR_DEFAULT_WIDTH); + const expandedWidth = Math.min(getLeftSidebarMaxWidth(), initialWidth + 40); + + await expect(handle).toBeVisible(); + await expect(handle).toHaveAttribute("aria-valuenow", String(initialWidth)); + await expect(sidebarWidth()).toBe(`${initialWidth}px`); + + dragSidebar(initialWidth, initialWidth + 40); + await waitFor(() => { + expect(sidebarWidth()).toBe(`${expandedWidth}px`); + }); + + dragSidebar(expandedWidth, 0); + await waitFor(() => { + expect(sidebarWidth()).toBe(`${LEFT_SIDEBAR_MIN_WIDTH}px`); + }); + + const maxWidth = getLeftSidebarMaxWidth(); + dragSidebar(LEFT_SIDEBAR_MIN_WIDTH, maxWidth + 1000); + await waitFor(() => { + expect(sidebarWidth()).toBe(`${maxWidth}px`); + }); + await waitFor(() => { + expect(localStorage.getItem(LEFT_SIDEBAR_STORAGE_KEY)).toBe( + String(maxWidth), + ); + }); + }, +}; + +const persistedLeftSidebarWidth = 380; + +export const PersistedResizableSidebarWidth: Story = { + args: { + chatList: [ + buildChat({ + id: "chat-resize-persisted", + title: "Persisted sidebar width agent", + updated_at: todayTimestamp, + }), + ], + }, + decorators: [ + (Story) => { + localStorage.setItem( + LEFT_SIDEBAR_STORAGE_KEY, + String(persistedLeftSidebarWidth), + ); + return ; + }, + ], + parameters: { + viewport: { defaultViewport: "ipad" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const sidebar = canvas.getByTestId("agents-sidebar-panel"); + const handle = canvas.getByRole("separator", { + name: "Resize agents sidebar", + }); + const sidebarWidth = () => + sidebar.style.getPropertyValue("--agents-left-sidebar-width"); + + await expect(handle).toHaveAttribute( + "aria-valuenow", + String(persistedLeftSidebarWidth), + ); + await expect(sidebarWidth()).toBe(`${persistedLeftSidebarWidth}px`); + }, +}; + +export const ResizableSidebarKeyboard: Story = { + args: { + chatList: [ + buildChat({ + id: "chat-resize-keyboard", + title: "Keyboard resizable sidebar agent", + updated_at: todayTimestamp, + }), + ], + }, + parameters: { + viewport: { defaultViewport: "ipad" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const sidebar = canvas.getByTestId("agents-sidebar-panel"); + const handle = canvas.getByRole("separator", { + name: "Resize agents sidebar", + }); + const sidebarWidth = () => + sidebar.style.getPropertyValue("--agents-left-sidebar-width"); + const initialWidth = clampLeftSidebarWidth(LEFT_SIDEBAR_DEFAULT_WIDTH); + const keyboardExpandedWidth = Math.min( + getLeftSidebarMaxWidth(), + initialWidth + LEFT_SIDEBAR_KEYBOARD_RESIZE_STEP, + ); + const maxWidth = getLeftSidebarMaxWidth(); + + handle.focus(); + await expect(handle).toHaveFocus(); + + fireEvent.keyDown(handle, { key: "ArrowRight" }); + await waitFor(() => { + expect(sidebarWidth()).toBe(`${keyboardExpandedWidth}px`); + expect(handle).toHaveAttribute( + "aria-valuenow", + String(keyboardExpandedWidth), + ); + }); + + fireEvent.keyDown(handle, { key: "Home" }); + await waitFor(() => { + expect(sidebarWidth()).toBe(`${LEFT_SIDEBAR_MIN_WIDTH}px`); + expect(handle).toHaveAttribute( + "aria-valuenow", + String(LEFT_SIDEBAR_MIN_WIDTH), + ); + }); + + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + await waitFor(() => { + expect(sidebarWidth()).toBe(`${LEFT_SIDEBAR_MIN_WIDTH}px`); + expect(handle).toHaveAttribute( + "aria-valuenow", + String(LEFT_SIDEBAR_MIN_WIDTH), + ); + }); + + fireEvent.keyDown(handle, { key: "End" }); + await waitFor(() => { + expect(sidebarWidth()).toBe(`${maxWidth}px`); + expect(handle).toHaveAttribute("aria-valuenow", String(maxWidth)); + }); + await waitFor(() => { + expect(localStorage.getItem(LEFT_SIDEBAR_STORAGE_KEY)).toBe( + String(maxWidth), + ); + }); + }, +}; + export const LoadingChats: Story = { args: { isChatsLoading: true, diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index f4d7787574..272caba6ec 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -9,6 +9,7 @@ import { isSettingsView, sidebarViewFromPath, } from "./components/Sidebar/AgentsSidebar"; +import { ResizableAgentsSidebarFrame } from "./components/Sidebar/ResizableAgentsSidebarFrame"; import type { ChatDetailError } from "./utils/usageLimitMessage"; export interface AgentsOutletContext { @@ -161,10 +162,9 @@ export const AgentsPageView: FC = ({ className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary sm:flex-row" > {pageTitle("Agents")} -
= ({ isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled} isAdmin={isAgentsAdmin} /> -
+
( -
-
-
-
-
-
- -
- - - -
-
- -
-
- -
- {Array.from({ length: 6 }, (_, i) => ( -
- -
- - -
+export const AgentsPageSkeleton: FC = () => { + const [leftSidebarWidth] = useState(() => loadPersistedLeftSidebarWidth()); + + return ( +
+
+
+
+
+
+ +
+ + +
- ))} +
+ +
+
+ +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+
-
-
-); + ); +}; /** * Skeleton placeholder for a chat conversation: two user message diff --git a/site/src/pages/AgentsPage/components/Sidebar/ResizableAgentsSidebarFrame.tsx b/site/src/pages/AgentsPage/components/Sidebar/ResizableAgentsSidebarFrame.tsx new file mode 100644 index 0000000000..0fe6a60ec9 --- /dev/null +++ b/site/src/pages/AgentsPage/components/Sidebar/ResizableAgentsSidebarFrame.tsx @@ -0,0 +1,140 @@ +import { + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + type PointerEvent as ReactPointerEvent, + useEffect, + useEffectEvent, + useRef, + useState, +} from "react"; +import { cn } from "#/utils/cn"; +import { + clampLeftSidebarWidth, + getLeftSidebarMaxWidth, + LEFT_SIDEBAR_KEYBOARD_RESIZE_STEP, + LEFT_SIDEBAR_MIN_WIDTH, + loadPersistedLeftSidebarWidth, + persistLeftSidebarWidth, +} from "./sidebarWidth"; + +interface ResizableAgentsSidebarFrameProps { + children: ReactNode; + className?: string; +} + +export const ResizableAgentsSidebarFrame = ({ + children, + className, +}: ResizableAgentsSidebarFrameProps) => { + const [width, setWidth] = useState(loadPersistedLeftSidebarWidth); + const maxWidth = getLeftSidebarMaxWidth(); + const isDragging = useRef(false); + const startX = useRef(0); + const startWidth = useRef(0); + + const setVisualWidth = (nextWidth: number): number => { + const clampedWidth = clampLeftSidebarWidth(nextWidth); + setWidth(clampedWidth); + return clampedWidth; + }; + + const setUserWidth = (nextWidth: number) => { + const clampedWidth = setVisualWidth(nextWidth); + persistLeftSidebarWidth(clampedWidth); + }; + + const handleResize = useEffectEvent(() => { + const clampedWidth = clampLeftSidebarWidth(width); + setVisualWidth(clampedWidth); + }); + + useEffect(() => { + globalThis.addEventListener("resize", handleResize); + return () => globalThis.removeEventListener("resize", handleResize); + }, []); + + const handlePointerDown = (e: ReactPointerEvent) => { + e.preventDefault(); + isDragging.current = true; + startX.current = e.clientX; + startWidth.current = width; + e.currentTarget.setPointerCapture?.(e.pointerId); + }; + + const handlePointerMove = (e: ReactPointerEvent) => { + if (!isDragging.current) { + return; + } + + const rawWidth = startWidth.current + (e.clientX - startX.current); + setUserWidth(rawWidth); + }; + + const handlePointerEnd = (e: ReactPointerEvent) => { + if (!isDragging.current) { + return; + } + + isDragging.current = false; + if (e.currentTarget.hasPointerCapture?.(e.pointerId)) { + e.currentTarget.releasePointerCapture?.(e.pointerId); + } + }; + + const handleKeyDown = (e: ReactKeyboardEvent) => { + switch (e.key) { + case "ArrowLeft": + e.preventDefault(); + setUserWidth(width - LEFT_SIDEBAR_KEYBOARD_RESIZE_STEP); + break; + case "ArrowRight": + e.preventDefault(); + setUserWidth(width + LEFT_SIDEBAR_KEYBOARD_RESIZE_STEP); + break; + case "Home": + e.preventDefault(); + setUserWidth(LEFT_SIDEBAR_MIN_WIDTH); + break; + case "End": + e.preventDefault(); + setUserWidth(getLeftSidebarMaxWidth()); + break; + } + }; + + return ( +
+ {children} +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/Sidebar/sidebarWidth.ts b/site/src/pages/AgentsPage/components/Sidebar/sidebarWidth.ts new file mode 100644 index 0000000000..48cfbe5048 --- /dev/null +++ b/site/src/pages/AgentsPage/components/Sidebar/sidebarWidth.ts @@ -0,0 +1,59 @@ +export const LEFT_SIDEBAR_STORAGE_KEY = "agents.left-sidebar-width"; +export const LEFT_SIDEBAR_MIN_WIDTH = 240; +export const LEFT_SIDEBAR_DEFAULT_WIDTH = 320; +// One rem gives keyboard users a predictable, fine-grained step. +export const LEFT_SIDEBAR_KEYBOARD_RESIZE_STEP = 16; +const LEFT_SIDEBAR_MAX_WIDTH = 660; +const LEFT_SIDEBAR_MAX_WIDTH_RATIO = 0.7; + +export function getLeftSidebarMaxWidth(): number { + return Math.max( + LEFT_SIDEBAR_MIN_WIDTH, + Math.min( + LEFT_SIDEBAR_MAX_WIDTH, + Math.floor(window.innerWidth * LEFT_SIDEBAR_MAX_WIDTH_RATIO), + ), + ); +} + +export function clampLeftSidebarWidth(width: number): number { + if (!Number.isFinite(width)) { + return clampLeftSidebarWidth(LEFT_SIDEBAR_DEFAULT_WIDTH); + } + return Math.min( + getLeftSidebarMaxWidth(), + Math.max(LEFT_SIDEBAR_MIN_WIDTH, Math.round(width)), + ); +} + +export function loadPersistedLeftSidebarWidth(): number { + let stored: string | null; + try { + stored = localStorage.getItem(LEFT_SIDEBAR_STORAGE_KEY); + } catch { + return clampLeftSidebarWidth(LEFT_SIDEBAR_DEFAULT_WIDTH); + } + + if (!stored) { + return clampLeftSidebarWidth(LEFT_SIDEBAR_DEFAULT_WIDTH); + } + + const parsed = Number.parseInt(stored, 10); + if ( + Number.isNaN(parsed) || + parsed < LEFT_SIDEBAR_MIN_WIDTH || + parsed > LEFT_SIDEBAR_MAX_WIDTH + ) { + return clampLeftSidebarWidth(LEFT_SIDEBAR_DEFAULT_WIDTH); + } + + return clampLeftSidebarWidth(parsed); +} + +export function persistLeftSidebarWidth(width: number): void { + try { + localStorage.setItem(LEFT_SIDEBAR_STORAGE_KEY, String(width)); + } catch { + // Ignore storage failures because resizing still works for this session. + } +}