diff --git a/src/renderer/src/components/sidebar/SidebarSeamScrollbarStrip.tsx b/src/renderer/src/components/sidebar/SidebarSeamScrollbarStrip.tsx deleted file mode 100644 index ab86fb1104c..00000000000 --- a/src/renderer/src/components/sidebar/SidebarSeamScrollbarStrip.tsx +++ /dev/null @@ -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 - 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) => { - 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) => { - 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 ( -
- ) -} diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index d234c4e7ade..0f910179661 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -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 : ( - - )}
{ 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,