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.
This commit is contained in:
Jaayden Halko
2026-05-07 10:21:53 +01:00
committed by GitHub
parent 10a22a4753
commit 9ec2df9574
5 changed files with 436 additions and 41 deletions
@@ -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<typeof AgentsPageView> = {
},
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 <Story />;
},
],
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,
+4 -4
View File
@@ -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<AgentsPageViewProps> = ({
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary sm:flex-row"
>
<title>{pageTitle("Agents")}</title>
<div
data-testid="agents-sidebar-panel"
<ResizableAgentsSidebarFrame
className={cn(
"sm:h-full sm:w-[320px] sm:min-h-0 sm:border-b-0",
"sm:h-full sm:min-h-0 sm:border-b-0",
agentId
? "hidden sm:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
: isSettingsDetail || isAnalytics
@@ -203,7 +203,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled}
isAdmin={isAgentsAdmin}
/>
</div>
</ResizableAgentsSidebarFrame>
<div
data-testid="agents-main-panel"
className={cn(
@@ -1,7 +1,8 @@
import type { FC } from "react";
import { type CSSProperties, type FC, useState } from "react";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { cn } from "#/utils/cn";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
import { loadPersistedLeftSidebarWidth } from "./Sidebar/sidebarWidth";
/** localStorage keys shared with the agents panel components. */
const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open";
@@ -28,48 +29,59 @@ function getRightPanelState(): { open: boolean; width: number } {
* sidebar + empty main area layout so the user sees structure
* immediately instead of a fullscreen spinner.
*/
export const AgentsPageSkeleton: FC = () => (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary sm:flex-row">
<div className="order-2 sm:order-none flex-1 min-h-0 border-t border-border-default sm:flex-none sm:border-t-0 sm:h-full sm:w-[320px] sm:min-h-0 sm:border-b-0">
<div className="relative flex h-full w-full min-h-0 border-0 border-r border-solid overflow-hidden">
<div className="absolute inset-0 flex flex-col">
<div className="hidden border-b border-border-default px-2 pb-3 pt-1.5 sm:block">
<div className="mb-2.5 flex items-center justify-between">
<Skeleton className="h-6 w-6 rounded" />
<div className="flex items-center gap-0.5 -mr-1.5">
<Skeleton className="h-7 w-7 rounded" />
<Skeleton className="h-7 w-7 rounded" />
<Skeleton className="h-7 w-7 rounded" />
</div>
</div>
<Skeleton className="h-9 w-full rounded-md" />
</div>
<div className="flex flex-col gap-2 px-2 py-3">
<Skeleton className="ml-2.5 h-3.5 w-16" />
<div className="flex flex-col gap-0.5">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1"
>
<Skeleton className="mt-0.5 h-5 w-5 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${55 + ((i * 17) % 35)}%` }}
/>
<Skeleton className="h-3 w-20" />
</div>
export const AgentsPageSkeleton: FC = () => {
const [leftSidebarWidth] = useState(() => loadPersistedLeftSidebarWidth());
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary sm:flex-row">
<div
style={
{
"--agents-left-sidebar-width": `${leftSidebarWidth}px`,
} as CSSProperties
}
className="order-2 sm:order-none flex-1 min-h-0 border-t border-border-default sm:flex-none sm:border-t-0 sm:h-full sm:w-[var(--agents-left-sidebar-width)] sm:min-w-[240px] sm:max-w-[min(520px,50vw)] sm:min-h-0 sm:border-b-0"
>
<div className="relative flex h-full w-full min-h-0 border-0 border-r border-solid overflow-hidden">
<div className="absolute inset-0 flex flex-col">
<div className="hidden border-b border-border-default px-2 pb-3 pt-1.5 sm:block">
<div className="mb-2.5 flex items-center justify-between">
<Skeleton className="h-6 w-6 rounded" />
<div className="flex items-center gap-0.5 -mr-1.5">
<Skeleton className="h-7 w-7 rounded" />
<Skeleton className="h-7 w-7 rounded" />
<Skeleton className="h-7 w-7 rounded" />
</div>
))}
</div>
<Skeleton className="h-9 w-full rounded-md" />
</div>
<div className="flex flex-col gap-2 px-2 py-3">
<Skeleton className="ml-2.5 h-3.5 w-16" />
<div className="flex flex-col gap-0.5">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1"
>
<Skeleton className="mt-0.5 h-5 w-5 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${55 + ((i * 17) % 35)}%` }}
/>
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary order-1 sm:order-none" />
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary order-1 sm:order-none" />
</div>
);
);
};
/**
* Skeleton placeholder for a chat conversation: two user message
@@ -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<HTMLDivElement>) => {
e.preventDefault();
isDragging.current = true;
startX.current = e.clientX;
startWidth.current = width;
e.currentTarget.setPointerCapture?.(e.pointerId);
};
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDragging.current) {
return;
}
const rawWidth = startWidth.current + (e.clientX - startX.current);
setUserWidth(rawWidth);
};
const handlePointerEnd = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDragging.current) {
return;
}
isDragging.current = false;
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
e.currentTarget.releasePointerCapture?.(e.pointerId);
}
};
const handleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
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 (
<div
data-testid="agents-sidebar-panel"
style={
{
"--agents-left-sidebar-width": `${width}px`,
"--agents-left-sidebar-min-width": `${LEFT_SIDEBAR_MIN_WIDTH}px`,
"--agents-left-sidebar-max-width": `${maxWidth}px`,
} as CSSProperties
}
className={cn(
className,
"relative sm:w-[var(--agents-left-sidebar-width)] sm:min-w-[var(--agents-left-sidebar-min-width)] sm:max-w-[var(--agents-left-sidebar-max-width)]",
)}
>
{children}
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize agents sidebar"
aria-valuemin={LEFT_SIDEBAR_MIN_WIDTH}
aria-valuemax={maxWidth}
aria-valuenow={width}
tabIndex={0}
data-testid="agents-sidebar-resize-handle"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onKeyDown={handleKeyDown}
className="absolute top-0 right-0 z-20 hidden h-full w-1 touch-none cursor-col-resize select-none transition-colors hover:bg-content-link focus-visible:bg-content-link focus-visible:outline-none sm:block"
/>
</div>
);
};
@@ -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.
}
}