Skip to content

feat: add draggable resize handle to sidebar - #136

Merged
aterrylu merged 2 commits into
mainfrom
terry/sidebar-resize
Apr 14, 2026
Merged

feat: add draggable resize handle to sidebar#136
aterrylu merged 2 commits into
mainfrom
terry/sidebar-resize

Conversation

@aterrylu

@aterrylu aterrylu commented Apr 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add a VS Code-style draggable resize handle on the right edge of the sidebar
  • Width persists to localStorage and survives page reload
  • Double-click handle to reset to default width (256px)

Changes

  • Sidebar.tsx — New SidebarResizeHandle component with onMouseDown → document mousemove/mouseup pattern, useRef + useEffect cleanup for safe unmount during drag
  • store.tssidebarWidth state field with setSidebarWidth (clamped, NaN-guarded) and resetSidebarWidth actions, persisted via partialize + validated in merge with upper-bound clamp
  • App.tsx — Render SidebarResizeHandle adjacent to Sidebar

Design decisions

  • Standalone selector for sidebarWidth — kept out of the useSidebarData useShallow bag so that sibling consumers (SessionViewManager, etc.) don't re-render during drag. Sidebar itself does re-render per pixel (it needs the width for <aside style={{ width }}>) but its tree is lightweight enough that this is acceptable.
  • useRef listener tracking — if the sidebar unmounts mid-drag (e.g., Cmd+B toggle), the useEffect cleanup removes orphaned document listeners and resets cursor/userSelect on document.body
  • Number.isFinite guard — rejects NaN/Infinity before they corrupt the store and persist to localStorage
  • Merge upper-bound clamp — a width saved on a 4K monitor is clamped to 50% of the current viewport on a smaller screen

Test plan

  • Drag handle to resize sidebar — width updates in real-time
  • Release mouse — width sticks
  • Reload page — width persists
  • Double-click handle — resets to 256px
  • Min width (180px) can't go lower, max (50% viewport) can't go higher
  • 14 unit tests for store actions (NaN, Infinity, clamping, reset)
  • 6 browser QA scenarios via Playwright

🤖 Generated with Claude Code

@aterrylu
aterrylu marked this pull request as ready for review April 14, 2026 08:49
@@ -405,8 +412,9 @@ export function Sidebar() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning

Problem: Sidebar subscribes to sidebarWidth directly via useStore((s) => s.sidebarWidth), so the entire sidebar tree re-renders on every pixel during drag — contradicting the PR claim that the standalone selector avoids this.

Why it matters: If the sidebar has many child components or non-trivial rendering, dragging the handle could cause visible jank. The PR description attributes the benefit to "standalone selector" but that only prevents re-renders in other components consuming useSidebarData — not in Sidebar itself.

Suggested fix: Move SidebarResizeHandle outside Sidebar's subscriber tree entirely. One option — use a ref-based approach that bypasses React state for the resize operation, and only sync back to store on drag-end:

// In SidebarResizeHandle — update DOM directly on mousemove
const sidebarRef = useRef<HTMLElement>(null);
const sidebarWidthRef = useRef(sidebarWidth);
const onMouseMove = useCallback((ev: MouseEvent) => {
  const w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(ev.clientX, window.innerWidth * 0.5));
  sidebarWidthRef.current = w;
  sidebarRef.current?.style.setProperty("--sidebar-width", `${w}px`);
}, []);
const onMouseUp = useCallback(() => {
  setSidebarWidth(sidebarWidthRef.current); // single state update on release
  setDragging(false);
  // ...
}, []);

Or accept the per-pixel re-renders if the sidebar is lightweight — in which case the PR description should be updated to remove the misleading claim.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the standalone selector protects sibling consumers of useSidebarData from re-rendering, but Sidebar itself does re-render per pixel since it needs the width for <aside style={{ width }}>. The sidebar tree is lightweight (nav buttons + agent list), so this is acceptable. Updated the PR description to clarify.

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid implementation overall — drag listeners, cleanup, NaN guard, and persistence merge logic all look correct. One performance concern noted inline about per-pixel re-renders during drag. Core functionality is solid; the sidebar resize works as intended.

@@ -231,6 +237,7 @@ export function Sidebar() {
sidebarViewMode,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning

Problem: Sidebar subscribes to sidebarWidth directly via useStore((s) => s.sidebarWidth) (line 237-238), so the entire Sidebar subtree re-renders on every mouse-move pixel during drag.

Why it matters: The PR description states the standalone selector avoids re-rendering the entire sidebar tree — but that only protects sibling/child components consuming useSidebarData. Sidebar itself still re-renders per pixel. If the sidebar has non-trivial rendering (many items, nested components), this causes visible jank during drag. The description should be corrected or the implementation adjusted.

Suggested fix: Update state only on mouseup (drag-end) instead of per mousemove. Write the new width to a useRef during drag, update the DOM directly via a CSS variable on the sidebar element, then commit the final value to the store on release:

// SidebarResizeHandle — track width in ref, update DOM directly, commit on mouseup
const sidebarWidthRef = useRef(sidebarWidth);
const onMouseMove = useCallback((ev: MouseEvent) => {
  const w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(ev.clientX, window.innerWidth * 0.5));
  sidebarWidthRef.current = w;
  document.documentElement.style.setProperty("--sidebar-width", `${w}px`);
  // Optionally show a live preview via CSS var
}, []);
const onMouseUp = useCallback(() => {
  setSidebarWidth(sidebarWidthRef.current); // single state update
  setDragging(false);
  // cleanup ...
}, []);

Then in Sidebar CSS: width: var(--sidebar-width, ${sidebarWidth}px) — falls back to store state when no CSS var is set (e.g., initial render).

If the sidebar is lightweight enough that per-pixel re-renders are acceptable, the PR description should be updated to remove the misleading claim.

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid feature overall. One inline comment about per-pixel re-renders during drag — either address the jank risk or clarify the PR description. NaN guard, unmount cleanup, persistence merge, and test coverage all look good. LGTM.

aterrylu and others added 2 commits April 14, 2026 02:09
Add a VS Code-style resize handle on the right edge of the sidebar that
allows users to adjust the sidebar width by dragging.

- Drag handle (4px) on sidebar's right edge, visible on hover
- Real-time resize with min (180px) / max (50% viewport) constraints
- Width persisted to zustand store (localStorage) across reloads
- Double-click handle to reset to default width (256px)
- useRef + useEffect cleanup prevents orphaned listeners on unmount
- NaN/Infinity guard on setSidebarWidth, upper-bound clamp on merge
- Extracted sidebarWidth into standalone selector to avoid full
  sidebar re-render on every drag pixel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 tests covering setSidebarWidth and resetSidebarWidth:
- Normal drag, min/max clamping, NaN/Infinity rejection
- Negative/zero handling, viewport-adaptive max
- Double-click reset from min/max extremes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aterrylu
aterrylu force-pushed the terry/sidebar-resize branch from 2ee1828 to 05bf94a Compare April 14, 2026 09:09
@aterrylu
aterrylu merged commit 1b9873d into main Apr 14, 2026
1 check passed
@aterrylu
aterrylu deleted the terry/sidebar-resize branch April 14, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants