Skip to content

Commit 946a2a7

Browse files
committed
Make the plan sidebar resizable
- Add a drag handle with pointer capture - Persist sidebar width in local storage - Clamp width to safe min and max values
1 parent 1b1aa19 commit 946a2a7

1 file changed

Lines changed: 116 additions & 2 deletions

File tree

apps/web/src/components/PlanSidebar.tsx

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useState, useCallback } from "react";
1+
import { memo, useState, useCallback, useRef, useEffect } from "react";
22
import { type TimestampFormat } from "../appSettings";
33
import { Badge } from "./ui/badge";
44
import { Button } from "./ui/button";
@@ -27,6 +27,13 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
2727
import { readNativeApi } from "~/nativeApi";
2828
import { toastManager } from "./ui/toast";
2929
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
30+
import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
31+
import { Schema } from "effect";
32+
33+
const PLAN_SIDEBAR_WIDTH_STORAGE_KEY = "plan_sidebar_width";
34+
const PLAN_SIDEBAR_DEFAULT_WIDTH = 340;
35+
const PLAN_SIDEBAR_MIN_WIDTH = 260;
36+
const PLAN_SIDEBAR_MAX_WIDTH = 800;
3037

3138
function stepStatusIcon(status: string): React.ReactNode {
3239
if (status === "completed") {
@@ -59,6 +66,101 @@ interface PlanSidebarProps {
5966
onClose: () => void;
6067
}
6168

69+
function clampWidth(width: number): number {
70+
return Math.max(PLAN_SIDEBAR_MIN_WIDTH, Math.min(width, PLAN_SIDEBAR_MAX_WIDTH));
71+
}
72+
73+
function useResizablePlanSidebar() {
74+
const [width, setWidth] = useState<number>(() => {
75+
const stored = getLocalStorageItem(PLAN_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite);
76+
return stored !== null ? clampWidth(stored) : PLAN_SIDEBAR_DEFAULT_WIDTH;
77+
});
78+
const resizeRef = useRef<{
79+
startX: number;
80+
startWidth: number;
81+
pointerId: number;
82+
moved: boolean;
83+
} | null>(null);
84+
const railRef = useRef<HTMLButtonElement | null>(null);
85+
86+
const handlePointerDown = useCallback(
87+
(event: React.PointerEvent<HTMLButtonElement>) => {
88+
if (event.button !== 0) return;
89+
event.preventDefault();
90+
event.stopPropagation();
91+
resizeRef.current = {
92+
startX: event.clientX,
93+
startWidth: width,
94+
pointerId: event.pointerId,
95+
moved: false,
96+
};
97+
event.currentTarget.setPointerCapture(event.pointerId);
98+
document.body.style.cursor = "col-resize";
99+
document.body.style.userSelect = "none";
100+
},
101+
[width],
102+
);
103+
104+
const handlePointerMove = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
105+
const state = resizeRef.current;
106+
if (!state || state.pointerId !== event.pointerId) return;
107+
event.preventDefault();
108+
// Dragging left increases width (right-side sidebar)
109+
const delta = state.startX - event.clientX;
110+
if (Math.abs(delta) > 2) {
111+
state.moved = true;
112+
}
113+
const newWidth = clampWidth(state.startWidth + delta);
114+
setWidth(newWidth);
115+
}, []);
116+
117+
const handlePointerUp = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
118+
const state = resizeRef.current;
119+
if (!state || state.pointerId !== event.pointerId) return;
120+
event.preventDefault();
121+
const delta = state.startX - event.clientX;
122+
const finalWidth = clampWidth(state.startWidth + delta);
123+
setLocalStorageItem(PLAN_SIDEBAR_WIDTH_STORAGE_KEY, finalWidth, Schema.Finite);
124+
resizeRef.current = null;
125+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
126+
event.currentTarget.releasePointerCapture(event.pointerId);
127+
}
128+
document.body.style.removeProperty("cursor");
129+
document.body.style.removeProperty("user-select");
130+
}, []);
131+
132+
const handlePointerCancel = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
133+
const state = resizeRef.current;
134+
if (!state || state.pointerId !== event.pointerId) return;
135+
resizeRef.current = null;
136+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
137+
event.currentTarget.releasePointerCapture(event.pointerId);
138+
}
139+
document.body.style.removeProperty("cursor");
140+
document.body.style.removeProperty("user-select");
141+
}, []);
142+
143+
// Cleanup on unmount
144+
useEffect(() => {
145+
return () => {
146+
document.body.style.removeProperty("cursor");
147+
document.body.style.removeProperty("user-select");
148+
};
149+
}, []);
150+
151+
return {
152+
width,
153+
railRef,
154+
railProps: {
155+
ref: railRef,
156+
onPointerDown: handlePointerDown,
157+
onPointerMove: handlePointerMove,
158+
onPointerUp: handlePointerUp,
159+
onPointerCancel: handlePointerCancel,
160+
},
161+
};
162+
}
163+
62164
const PlanSidebar = memo(function PlanSidebar({
63165
activePlan,
64166
activeProposedPlan,
@@ -70,6 +172,7 @@ const PlanSidebar = memo(function PlanSidebar({
70172
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
71173
const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
72174
const { copyToClipboard, isCopied } = useCopyToClipboard();
175+
const { width, railProps } = useResizablePlanSidebar();
73176

74177
const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
75178
const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
@@ -118,7 +221,18 @@ const PlanSidebar = memo(function PlanSidebar({
118221
}, [planMarkdown, workspaceRoot]);
119222

120223
return (
121-
<div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50">
224+
<div
225+
className="relative flex h-full shrink-0 flex-col border-l border-border/70 bg-card/50"
226+
style={{ width: `${width}px` }}
227+
>
228+
{/* Resize handle */}
229+
<button
230+
type="button"
231+
aria-label="Resize plan sidebar"
232+
title="Drag to resize"
233+
className="absolute inset-y-0 left-0 z-20 w-1 -translate-x-1/2 cursor-col-resize touch-none select-none hover:bg-primary/20 active:bg-primary/30 transition-colors"
234+
{...railProps}
235+
/>
122236
{/* Header */}
123237
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3">
124238
<div className="flex items-center gap-2">

0 commit comments

Comments
 (0)