fix(sidebar): stop a missed pointerup from hiding a remote host section (#19032)

Clicking a host header arms a drag session on pointerdown, but the window
pointermove/pointerup listeners attach from an effect gated on that state --
a render and a paint later. On a heavy sidebar a quick click's pointerup can
land inside that window and never be seen, so the session survives the click
and the next bare mouse move clears the 4px threshold and promotes a drag the
user is not doing.

The host tier is the only header that hides itself while dragging (opacity-0,
plus forceCollapseHosts on every section), so the host the user just collapsed
vanishes outright. It only returns on a stray later pointerup or when the
viewport remounts -- which is why toggling the host filter fixes it: the
viewport's React key includes visibleWorkspaceHostIds.

Treat a pointermove with no button held as a released pointer and end the
session instead of promoting. The repo and project-group header drags share
the race, where it commits an unintended reorder on the next click, so they
get the same guard. Extracting their duplicated click-swallow block keeps
project-header-drag.ts under the max-lines ceiling.
This commit is contained in:
Neil
2026-09-05 23:47:47 -07:00
committed by GitHub
parent a37a0b50d1
commit ced8a93bfd
6 changed files with 147 additions and 39 deletions
@@ -0,0 +1,20 @@
/**
* Swallow the click that follows a completed header drag.
*
* Why: the pointerup that ends a promoted drag is followed by a click on the
* drag handle, which would also toggle the section the user just reordered.
* The listener removes itself on the first click; the returned timeout handle
* is the fallback for a drop that produces no click.
*/
export function swallowNextClickOnDragHandle(handleEl: HTMLElement): ReturnType<typeof setTimeout> {
const swallow = (event: MouseEvent): void => {
const target = event.target as Node | null
if (target && handleEl.contains(target)) {
event.stopPropagation()
event.preventDefault()
}
window.removeEventListener('click', swallow, true)
}
window.addEventListener('click', swallow, true)
return setTimeout(() => window.removeEventListener('click', swallow, true), 0)
}
@@ -0,0 +1,14 @@
/**
* True when a pointermove arrives with no button held, meaning the pointerup
* that should have ended the armed drag never reached us.
*
* Why: the header drag hooks subscribe to window pointer events from an effect
* armed by pointerdown state, so a fast click's release can land before that
* effect runs (a heavy sidebar render sits between them). The session then
* survives the click and the next hover promotes a drag the user is not doing —
* for host sections that hides the header outright and force-collapses every
* host. A capture-phase listener swallowing pointerup has the same effect.
*/
export function hasPointerBeenReleased(event: PointerEvent): boolean {
return event.buttons === 0
}
@@ -0,0 +1,92 @@
// @vitest-environment happy-dom
import React from 'react'
import { act, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useHostHeaderDrag } from './host-header-drag'
import type { ExecutionHostId } from '../../../../shared/execution-host'
function setup() {
const scrollContainer = document.createElement('div')
document.body.append(scrollContainer)
const controller: { current: ReturnType<typeof useHostHeaderDrag> | null } = { current: null }
function Harness(): React.JSX.Element {
const drag = useHostHeaderDrag({
orderedHostIds: ['ssh:host-a', 'ssh:host-b'] as ExecutionHostId[],
onCommit: vi.fn(),
getScrollContainer: () => scrollContainer
})
controller.current = drag
return (
<div
data-host-header-drag-id="ssh:host-a"
onPointerDown={(event) => drag.onHandlePointerDown(event, 'ssh:host-a')}
/>
)
}
const view = render(<Harness />)
const header = view.container.querySelector<HTMLElement>('[data-host-header-drag-id]')!
header.setPointerCapture = vi.fn()
header.releasePointerCapture = vi.fn()
return { controller, header }
}
function pointer(type: string, init: PointerEventInit): PointerEvent {
return new PointerEvent(type, { bubbles: true, pointerId: 1, ...init })
}
describe('useHostHeaderDrag', () => {
it('does not start a drag when the pointer is released before the window listeners attach', () => {
const { controller, header } = setup()
// A click: pointerdown arms the session, pointerup lands before React has
// flushed the passive effect that subscribes to window pointer events.
act(() => {
header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 }))
window.dispatchEvent(pointer('pointerup', { clientX: 10, clientY: 10 }))
})
// Moving the mouse afterwards, with no button held, must not promote a drag.
act(() => {
window.dispatchEvent(pointer('pointermove', { clientX: 200, clientY: 400, buttons: 0 }))
})
expect(controller.current?.state.draggingHostId).toBeNull()
})
it('clears a session whose pointerup was missed so a later drag still works', () => {
const { controller, header } = setup()
act(() => {
header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 }))
window.dispatchEvent(pointer('pointerup', { clientX: 10, clientY: 10 }))
})
act(() => {
window.dispatchEvent(pointer('pointermove', { clientX: 200, clientY: 400, buttons: 0 }))
})
act(() => {
header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 }))
})
act(() => {
window.dispatchEvent(pointer('pointermove', { clientX: 40, clientY: 60, buttons: 1 }))
})
expect(controller.current?.state.draggingHostId).toBe('ssh:host-a')
})
it('still promotes a drag while the pointer stays down', () => {
const { controller, header } = setup()
act(() => {
header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 }))
})
act(() => {
window.dispatchEvent(pointer('pointermove', { clientX: 40, clientY: 60, buttons: 1 }))
})
expect(controller.current?.state.draggingHostId).toBe('ssh:host-a')
})
})
@@ -16,6 +16,8 @@ import {
readHostHeaderRects,
type HostHeaderRect
} from './host-header-drag-dom'
import { hasPointerBeenReleased } from './header-drag-pointer-release'
import { swallowNextClickOnDragHandle } from './header-drag-click-swallow'
export type HostDragState = {
draggingHostId: ExecutionHostId | null
@@ -157,17 +159,7 @@ export function useHostHeaderDrag({
session.preview?.remove()
setSidebarPointerDragDocumentStyles(false)
if (session.promoted) {
const handleEl = session.handleEl
const swallow = (e: MouseEvent): void => {
const target = e.target as Node | null
if (target && handleEl.contains(target)) {
e.stopPropagation()
e.preventDefault()
}
window.removeEventListener('click', swallow, true)
}
window.addEventListener('click', swallow, true)
setTimeout(() => window.removeEventListener('click', swallow, true), 0)
swallowNextClickOnDragHandle(session.handleEl)
}
const finalIndex =
commit && session.promoted
@@ -206,6 +198,10 @@ export function useHostHeaderDrag({
if (!session || e.pointerId !== session.pointerId) {
return
}
if (hasPointerBeenReleased(e)) {
endDrag(false)
return
}
if (!session.promoted) {
const dx = e.clientX - session.startX
const dy = e.clientY - session.startY
@@ -15,6 +15,8 @@ import {
} from './project-group-header-drag-contract'
import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start'
import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll'
import { hasPointerBeenReleased } from './header-drag-pointer-release'
import { swallowNextClickOnDragHandle } from './header-drag-click-swallow'
// Why pointer events instead of HTML5 DnD: Project Group rows are virtualized
// and may unmount while scrolling; cached row-model indices keep drops stable.
@@ -114,20 +116,7 @@ export function useProjectGroupHeaderDrag({
// capture may already be released (pointercancel, element unmounted)
}
if (session.promoted) {
const handleEl = session.handleEl
const swallow = (event: MouseEvent): void => {
const target = event.target as Node | null
if (target && handleEl.contains(target)) {
event.stopPropagation()
event.preventDefault()
}
window.removeEventListener('click', swallow, true)
}
window.addEventListener('click', swallow, true)
clickSwallowTimeoutRef.current = setTimeout(() => {
window.removeEventListener('click', swallow, true)
clickSwallowTimeoutRef.current = null
}, 0)
clickSwallowTimeoutRef.current = swallowNextClickOnDragHandle(session.handleEl)
}
const sidebarDropIndex =
commit && session.promoted && latestDropIndexRef.current !== null
@@ -199,6 +188,10 @@ export function useProjectGroupHeaderDrag({
if (!session || event.pointerId !== session.pointerId) {
return
}
if (hasPointerBeenReleased(event)) {
endDrag(false)
return
}
session.latestPointerY = event.clientY
if (!session.promoted) {
const dx = event.clientX - session.startX
@@ -15,6 +15,8 @@ import {
} from './project-header-drag-contract'
import { createProjectHeaderDragSession } from './project-header-drag-start'
import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll'
import { hasPointerBeenReleased } from './header-drag-pointer-release'
import { swallowNextClickOnDragHandle } from './header-drag-click-swallow'
// Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by
// react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire
@@ -124,20 +126,7 @@ export function useRepoHeaderDrag({
// capture may already be released (pointercancel, element unmounted)
}
if (session.promoted) {
const handleEl = session.handleEl
const swallow = (e: MouseEvent): void => {
const target = e.target as Node | null
if (target && handleEl.contains(target)) {
e.stopPropagation()
e.preventDefault()
}
window.removeEventListener('click', swallow, true)
}
window.addEventListener('click', swallow, true)
clickSwallowTimeoutRef.current = setTimeout(() => {
window.removeEventListener('click', swallow, true)
clickSwallowTimeoutRef.current = null
}, 0)
clickSwallowTimeoutRef.current = swallowNextClickOnDragHandle(session.handleEl)
}
const sidebarDropIndex =
commit && session.promoted && latestDropIndexRef.current !== null
@@ -212,6 +201,10 @@ export function useRepoHeaderDrag({
if (!session || e.pointerId !== session.pointerId) {
return
}
if (hasPointerBeenReleased(e)) {
endDrag(false)
return
}
session.latestPointerY = e.clientY
if (!session.promoted) {
const dx = e.clientX - session.startX