mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user