fix(sidebar): let the divider resize instead of scrolling the workspace list (#40087)

* fix(sidebar): let the divider resize instead of scrolling the workspace list

The 4px scrollbar strip past the seam covered the visible divider pixel, so
grabbing the divider over a scrolling list dragged the scrollbar. Drop the
strip and start the resize handle on the divider; the native scrollbar inside
the sidebar is unchanged.

* fix(sidebar): trim the resize handle's outer reach by 1px
This commit is contained in:
Neil
2026-11-08 20:41:06 -08:00
committed by GitHub
parent 4652a35f87
commit 803e257954
3 changed files with 17 additions and 158 deletions
@@ -1,139 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react'
// Chromium keeps a native thumb at least about this tall on long lists.
const NATIVE_THUMB_MIN_HEIGHT = 18
type SidebarSeamScrollbarStripProps = {
sidebarRef: React.RefObject<HTMLElement | null>
onResizeStart: (event: React.MouseEvent) => void
}
function findSeamScrollContainer(sidebar: HTMLElement, clientY: number): HTMLElement | null {
const seamX = sidebar.getBoundingClientRect().right
let node = document.elementFromPoint(seamX - 2, clientY)
while (node instanceof HTMLElement && sidebar.contains(node)) {
const overflowY = getComputedStyle(node).overflowY
if (
(overflowY === 'auto' || overflowY === 'scroll') &&
node.scrollHeight > node.clientHeight + 1 &&
Math.abs(node.getBoundingClientRect().right - seamX) <= 1
) {
return node
}
node = node.parentElement
}
return null
}
function getThumbGeometry(container: HTMLElement): {
trackTop: number
travel: number
thumbTop: number
thumbHeight: number
maxScrollTop: number
} {
const trackTop = container.getBoundingClientRect().top
const trackHeight = container.clientHeight
const maxScrollTop = Math.max(1, container.scrollHeight - container.clientHeight)
const thumbHeight = Math.min(
trackHeight,
Math.max(
NATIVE_THUMB_MIN_HEIGHT,
(trackHeight * container.clientHeight) / container.scrollHeight
)
)
const travel = Math.max(1, trackHeight - thumbHeight)
return {
trackTop,
travel,
thumbTop: trackTop + (container.scrollTop / maxScrollTop) * travel,
thumbHeight,
maxScrollTop
}
}
/** Extends the sidebar list's scrollbar hit area a few pixels past the seam into the main pane. */
export function SidebarSeamScrollbarStrip({
sidebarRef,
onResizeStart
}: SidebarSeamScrollbarStripProps): React.JSX.Element {
const endDragRef = useRef<(() => void) | null>(null)
useEffect(() => () => endDragRef.current?.(), [])
const handleMouseMove = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const sidebar = sidebarRef.current
const overScrollbar = sidebar ? findSeamScrollContainer(sidebar, event.clientY) : null
event.currentTarget.style.cursor = overScrollbar ? 'default' : 'col-resize'
},
[sidebarRef]
)
const handleMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const sidebar = sidebarRef.current
const container = sidebar ? findSeamScrollContainer(sidebar, event.clientY) : null
if (!container) {
onResizeStart(event)
return
}
if (event.button !== 0) {
return
}
event.preventDefault()
endDragRef.current?.()
const start = getThumbGeometry(container)
const grabbedThumb =
event.clientY >= start.thumbTop && event.clientY <= start.thumbTop + start.thumbHeight
const grabOffset = grabbedThumb ? event.clientY - start.thumbTop : start.thumbHeight / 2
const scrollToPointer = (clientY: number): void => {
const geometry = getThumbGeometry(container)
const thumbTop = Math.max(
0,
Math.min(geometry.travel, clientY - geometry.trackTop - grabOffset)
)
container.scrollTop = (thumbTop / geometry.travel) * geometry.maxScrollTop
}
if (!grabbedThumb) {
scrollToPointer(event.clientY)
}
const onMove = (moveEvent: MouseEvent): void => {
moveEvent.preventDefault()
scrollToPointer(moveEvent.clientY)
}
// Why: a viewport overlay keeps mouseup over native plugins such as PDF embeds, preventing a stuck drag.
const overlay = document.createElement('div')
overlay.style.position = 'fixed'
overlay.style.inset = '0'
overlay.style.zIndex = '2147483647'
const endDrag = (): void => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', endDrag)
window.removeEventListener('blur', endDrag)
overlay.remove()
document.body.style.userSelect = ''
endDragRef.current = null
}
document.body.appendChild(overlay)
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', endDrag)
window.addEventListener('blur', endDrag)
endDragRef.current = endDrag
},
[onResizeStart, sidebarRef]
)
return (
<div
data-sidebar-seam-scrollbar-strip=""
className="absolute -right-1 top-0 z-20 h-full w-1"
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
/>
)
}
@@ -5,7 +5,6 @@ import { useSidebarResize } from '@/hooks/useSidebarResize'
import SidebarHeader from './SidebarHeader'
import SidebarNav from './SidebarNav'
import { setSidebarLiveWidth } from './sidebar-live-width'
import { SidebarSeamScrollbarStrip } from './SidebarSeamScrollbarStrip'
import SetupScriptPromptCard from './SetupScriptPromptCard'
import WorktreeList from './worktree-list/listing/WorktreeList'
import WslMovedProjectsSection from './WslMovedProjectsSection'
@@ -46,9 +45,9 @@ const AgentDashboardSidebarHost = lazyWithRetry(() => import('./AgentDashboardSi
const MIN_WIDTH = 220
const MAX_WIDTH = 500
// Why: stay above retained panes and past the seam strip so scrolling and resizing remain separate.
// Why: start on the divider pixel so pointing at the visible seam resizes instead of scrolling the list.
export const WORKTREE_SIDEBAR_RESIZE_HANDLE_CLASS_NAME =
'peer absolute -right-3.5 top-0 z-20 h-full w-2.5 cursor-col-resize'
'peer absolute -right-[9px] top-0 z-20 h-full w-[9px] cursor-col-resize'
// Why: an open drawer covers the outer side of the seam, so keep the inner overlap wide enough to grab.
const WORKTREE_SIDEBAR_RESIZE_HANDLE_UNDER_DRAWER_CLASS_NAME = '-right-1.5 w-3'
export const WORKTREE_SIDEBAR_RESIZE_HANDLE_LINE_CLASS_NAME =
@@ -260,9 +259,6 @@ function Sidebar({
{sidebarOpen && (
<>
{drawerCoversSeam ? null : (
<SidebarSeamScrollbarStrip sidebarRef={containerRef} onResizeStart={onResizeStart} />
)}
<div
data-sidebar-resize-handle=""
className={cn(
+15 -13
View File
@@ -12,29 +12,28 @@ test.describe('Sidebar boundaries beside terminal content', () => {
await ensureTerminalVisible(orcaPage)
})
test('sidebar resize and scrollbar targets remain reachable over a terminal', async ({
orcaPage
}) => {
test('sidebar resize handle owns the divider over a terminal', async ({ orcaPage }) => {
const handle = orcaPage.locator('[data-sidebar-resize-handle]')
const strip = orcaPage.locator('[data-sidebar-seam-scrollbar-strip]')
const sidebar = orcaPage
.locator('[data-sidebar-resize-handle]')
.locator('xpath=preceding-sibling::div[last()]')
await expect(handle).toBeVisible()
await expect(orcaPage.locator('.xterm:visible').first()).toBeVisible()
const terminalBox = await orcaPage.locator('.xterm:visible').first().boundingBox()
const handleBox = await handle.boundingBox()
const stripBox = await strip.boundingBox()
const sidebarBox = await sidebar.boundingBox()
expect(terminalBox).not.toBeNull()
expect(handleBox).not.toBeNull()
expect(stripBox).not.toBeNull()
expect(sidebarBox).not.toBeNull()
const y = terminalBox!.y + terminalBox!.height / 2
const seamX = sidebarBox!.x + sidebarBox!.width
for (const [target, box] of [
[handle, handleBox!],
[strip, stripBox!]
] as const) {
const reachable = await target.evaluate(
// The visible divider pixel and the area just right of it must resize, not scroll.
for (const x of [seamX + 0.5, handleBox!.x + handleBox!.width / 2]) {
const reachable = await handle.evaluate(
(element, point) => element.contains(document.elementFromPoint(point.x, point.y)),
{ x: box.x + box.width / 2, y }
{ x, y }
)
expect(reachable).toBe(true)
}
@@ -64,7 +63,10 @@ test.describe('Sidebar boundaries beside terminal content', () => {
throw new Error('window.__store is not available — is the app in dev mode?')
}
const state = store.getState()
return { activeView: state.activeView, sidebarOpen: state.sidebarOpen }
return {
activeView: state.activeView,
sidebarOpen: state.sidebarOpen
}
}),
{
timeout: 5_000,