feat(sidebar): add jump-to-top button for hard upward scrolling (#13864)

* feat(sidebar): add jump-to-top button for hard upward scrolling

Detect intentional hard scroll-up gestures (wheel or scrollbar drag) and
offer a one-click jump-to-top affordance. Auto-hide after idle to avoid
persistent visual clutter. Addresses the common case of fast navigation
through long worktree lists ranked by agent activity.

* test(sidebar): improve scroll-to-top detection for active gestures

Only detect velocity from active scrollbar drag or touch, not
programmatic scrolls. Return focus to list after jump-to-top. Add
comprehensive hook tests with gesture simulation. Improve cumulative
down-delta tracking for dismissal.

* test(sidebar): add scroll-to-top gesture detection tests

Add comprehensive test coverage for the hard-upward-scroll detection hook,
verifying idle timer behavior, gesture suppression, scrollability checks,
and cleanup on unmount. Extract the post-jump suppression window into a
named constant for maintainability.
This commit is contained in:
Jinjing
2026-08-11 15:56:07 -07:00
committed by GitHub
parent 077f5a11cd
commit 648d17372b
8 changed files with 1170 additions and 0 deletions
@@ -147,6 +147,8 @@ import {
} from '@/hooks/useVirtualizedScrollAnchor'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry'
import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top'
import { WorktreeListScrollToTopButton } from './WorktreeListScrollToTopButton'
import {
getFolderWorkspacePathStatusDescription,
getFolderWorkspacePathStatusTitle
@@ -1374,6 +1376,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
scrollAnchorRef
}: VirtualizedWorktreeViewportProps) {
const scrollRef = useRef<HTMLDivElement>(null)
// Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach.
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
const suppressMeasurementAdjustmentUntilRef = useRef(0)
const directScrollInputUntilRef = useRef(0)
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
@@ -2038,6 +2042,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
suppressMeasurementAdjustmentUntilRef.current = suppressUntil
directScrollInputUntilRef.current = suppressUntil
}, [])
const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({
scrollElement,
onUserScrollIntent: markDirectScrollInput
})
const hasDirectScrollInput = useCallback(
() => window.performance.now() < directScrollInputUntilRef.current,
[]
@@ -2656,6 +2664,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
clearWorktreeDrag()
}
scrollRef.current = node
setScrollElement(node)
},
[
cancelPendingRevealFrames,
@@ -5171,6 +5180,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
})}
</div>
</div>
{showScrollToTop ? <WorktreeListScrollToTopButton onClick={scrollToTop} /> : null}
</div>
)
})
@@ -0,0 +1,50 @@
import { ChevronsUp } from 'lucide-react'
import React from 'react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
export function WorktreeListScrollToTopButton({
onClick,
className
}: {
onClick: () => void
className?: string
}): React.JSX.Element {
const label = translate(
'auto.components.sidebar.WorktreeListScrollToTopButton.jumpToTop',
'Jump to top'
)
return (
<div
className={cn(
'pointer-events-none absolute inset-x-0 top-2 z-40 flex justify-center',
className
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
onClick={onClick}
className={cn(
'pointer-events-auto size-6 text-muted-foreground',
'hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground',
'animate-in fade-in-0 duration-150 motion-reduce:animate-none'
)}
>
<ChevronsUp className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
</div>
)
}
@@ -0,0 +1,175 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top'
import { HARD_SCROLL_UP } from './worktree-list-hard-scroll-up'
let clockMs = 0
function advance(ms: number): void {
clockMs += ms
act(() => {
vi.advanceTimersByTime(ms)
})
}
function createViewportElement({
scrollTop = 1200,
scrollHeight = 5000,
clientHeight = 1000
}: { scrollTop?: number; scrollHeight?: number; clientHeight?: number } = {}): HTMLElement {
const element = document.createElement('div')
// happy-dom reports zero layout; stub the metrics the detector reads.
Object.defineProperty(element, 'scrollHeight', { value: scrollHeight, configurable: true })
Object.defineProperty(element, 'clientHeight', { value: clientHeight, configurable: true })
Object.defineProperty(element, 'scrollTop', {
value: scrollTop,
configurable: true,
writable: true
})
element.scrollTo = vi.fn(() => {
element.scrollTop = 0
}) as unknown as HTMLElement['scrollTo']
element.focus = vi.fn()
document.body.append(element)
return element
}
/** Enough upward wheel travel to clear `hardTotalDeltaPx` inside the intent window. */
function dispatchHardWheelUp(element: HTMLElement, samples = 4): void {
for (let i = 0; i < samples; i += 1) {
clockMs += 16
act(() => {
element.dispatchEvent(new WheelEvent('wheel', { deltaY: -240 }))
})
}
}
describe('useWorktreeListScrollToTop', () => {
beforeEach(() => {
clockMs = 0
vi.useFakeTimers()
vi.spyOn(window.performance, 'now').mockImplementation(() => clockMs)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('shows on a hard upward wheel gesture and hides on the idle deadline', () => {
const element = createViewportElement()
const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
dispatchHardWheelUp(element)
expect(result.current.showScrollToTop).toBe(true)
advance(HARD_SCROLL_UP.hideAfterIdleMs)
expect(result.current.showScrollToTop).toBe(false)
})
it('does not extend the idle deadline on non-intent scroll noise', () => {
const element = createViewportElement()
const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
dispatchHardWheelUp(element)
const intentAt = clockMs
// Let the hard samples age out so later ticks are judged on their own.
advance(HARD_SCROLL_UP.windowMs + 16)
// Gentle upward ticks keep arriving but never refresh intent.
for (let i = 0; i < 6; i += 1) {
advance(200)
act(() => {
element.dispatchEvent(new WheelEvent('wheel', { deltaY: -4 }))
})
}
expect(clockMs - intentAt).toBeLessThan(HARD_SCROLL_UP.hideAfterIdleMs)
expect(result.current.showScrollToTop).toBe(true)
advance(intentAt + HARD_SCROLL_UP.hideAfterIdleMs - clockMs)
expect(result.current.showScrollToTop).toBe(false)
})
it('re-arms the idle timer when intent is refreshed', () => {
const element = createViewportElement()
const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
dispatchHardWheelUp(element)
advance(HARD_SCROLL_UP.hideAfterIdleMs - 200)
expect(result.current.showScrollToTop).toBe(true)
dispatchHardWheelUp(element)
advance(HARD_SCROLL_UP.hideAfterIdleMs - 200)
expect(result.current.showScrollToTop).toBe(true)
advance(200)
expect(result.current.showScrollToTop).toBe(false)
})
it('suppresses detection for the post-jump window after scrollToTop', () => {
const element = createViewportElement()
const onUserScrollIntent = vi.fn()
const { result } = renderHook(() =>
useWorktreeListScrollToTop({ scrollElement: element, onUserScrollIntent })
)
dispatchHardWheelUp(element)
act(() => {
result.current.scrollToTop()
})
const jumpAt = clockMs
expect(onUserScrollIntent).toHaveBeenCalledTimes(1)
expect(element.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'auto' })
expect(result.current.showScrollToTop).toBe(false)
// Momentum events land back at depth while suppression is active.
element.scrollTop = 1200
dispatchHardWheelUp(element)
expect(clockMs).toBeLessThan(jumpAt + HARD_SCROLL_UP.suppressAfterJumpMs)
expect(result.current.showScrollToTop).toBe(false)
advance(HARD_SCROLL_UP.suppressAfterJumpMs)
dispatchHardWheelUp(element)
expect(result.current.showScrollToTop).toBe(true)
})
it('force-hides when the list stops being scrollable, even mid-gesture', () => {
const element = createViewportElement()
const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
dispatchHardWheelUp(element)
expect(result.current.showScrollToTop).toBe(true)
Object.defineProperty(element, 'scrollHeight', { value: 1100, configurable: true })
act(() => {
element.dispatchEvent(new WheelEvent('wheel', { deltaY: -240 }))
})
expect(result.current.showScrollToTop).toBe(false)
// The pending idle timer was cleared, so nothing flips state later.
advance(HARD_SCROLL_UP.hideAfterIdleMs * 2)
expect(result.current.showScrollToTop).toBe(false)
})
it('clears the timer and detaches listeners when the scroll element goes away', () => {
const element = createViewportElement()
const { result, rerender } = renderHook(
({ scrollElement }: { scrollElement: HTMLElement | null }) =>
useWorktreeListScrollToTop({ scrollElement }),
{ initialProps: { scrollElement: element as HTMLElement | null } }
)
dispatchHardWheelUp(element)
expect(result.current.showScrollToTop).toBe(true)
rerender({ scrollElement: null })
expect(result.current.showScrollToTop).toBe(false)
dispatchHardWheelUp(element)
advance(HARD_SCROLL_UP.hideAfterIdleMs * 2)
expect(result.current.showScrollToTop).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top'
function createScroller(): HTMLElement {
const element = document.createElement('div')
element.tabIndex = 0
document.body.append(element)
Object.defineProperties(element, {
scrollHeight: { configurable: true, value: 5000 },
clientHeight: { configurable: true, value: 500 },
// 16px narrower than offsetWidth so the scrollbar hit-test has a realistic gutter.
clientWidth: { configurable: true, value: 284 },
offsetWidth: { configurable: true, value: 300 }
})
element.getBoundingClientRect = () => ({
bottom: 500,
height: 500,
left: 0,
right: 300,
top: 0,
width: 300,
x: 0,
y: 0,
toJSON: () => ({})
})
return element
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
document.body.replaceChildren()
})
describe('useWorktreeListScrollToTop', () => {
it('ignores fast programmatic scrolling', () => {
const element = createScroller()
let now = 0
vi.spyOn(window.performance, 'now').mockImplementation(() => now)
const view = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
for (const sample of [
{ t: 0, scrollTop: 2200 },
{ t: 80, scrollTop: 2000 },
{ t: 170, scrollTop: 1700 }
]) {
now = sample.t
element.scrollTop = sample.scrollTop
act(() => element.dispatchEvent(new Event('scroll')))
}
expect(view.result.current.showScrollToTop).toBe(false)
})
it('detects velocity while the scrollbar is actively dragged', () => {
const element = createScroller()
let now = 0
vi.spyOn(window.performance, 'now').mockImplementation(() => now)
const view = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
act(() =>
element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 295, pointerType: 'mouse' }))
)
for (const sample of [
{ t: 0, scrollTop: 2200 },
{ t: 80, scrollTop: 2000 },
{ t: 170, scrollTop: 1700 }
]) {
now = sample.t
element.scrollTop = sample.scrollTop
act(() => element.dispatchEvent(new Event('scroll')))
}
expect(view.result.current.showScrollToTop).toBe(true)
})
it('returns focus to the list after jumping to the top', () => {
const element = createScroller()
element.scrollTop = 1200
element.scrollTo = vi.fn(({ top }) => {
element.scrollTop = Number(top)
})
const view = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
act(() => {
for (let index = 0; index < 4; index += 1) {
element.dispatchEvent(new WheelEvent('wheel', { deltaY: -200 }))
}
})
const button = document.createElement('button')
document.body.append(button)
button.focus()
act(() => view.result.current.scrollToTop())
expect(element.scrollTop).toBe(0)
expect(document.activeElement).toBe(element)
})
})
@@ -0,0 +1,230 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
createHardScrollUpDetectorState,
HARD_SCROLL_UP,
reduceHardScrollUpOnDismiss,
reduceHardScrollUpOnIdle,
reduceHardScrollUpOnScroll,
reduceHardScrollUpOnWheel,
type HardScrollUpDetectorState
} from './worktree-list-hard-scroll-up'
function readViewport(element: HTMLElement): { scrollTop: number; maxScroll: number } {
const maxScroll = Math.max(0, element.scrollHeight - element.clientHeight)
return {
scrollTop: element.scrollTop,
maxScroll
}
}
function shouldForceHide(viewport: { scrollTop: number; maxScroll: number }): boolean {
return (
viewport.scrollTop <= HARD_SCROLL_UP.nearTopPx ||
viewport.maxScroll < HARD_SCROLL_UP.minScrollablePx
)
}
/**
* Offers a jump-to-top affordance when the user is hard-scrolling up a long
* worktree list (common with smart/agent-activity ranking).
*
* Visibility is deadline-based from `lastIntentAt` so scroll noise cannot keep
* the button stuck forever.
*/
export function useWorktreeListScrollToTop({
scrollElement,
onUserScrollIntent
}: {
scrollElement: HTMLElement | null
/** Called when the user clicks jump-to-top so virtualizer scroll guards engage. */
onUserScrollIntent?: () => void
}): {
showScrollToTop: boolean
scrollToTop: () => void
} {
const detectorRef = useRef<HardScrollUpDetectorState>(createHardScrollUpDetectorState())
const [showScrollToTop, setShowScrollToTop] = useState(false)
const showScrollToTopRef = useRef(false)
const idleTimerRef = useRef<number | null>(null)
const scrollbarDragRef = useRef(false)
const touchScrollRef = useRef(false)
// Why: jump-to-top can still emit a burst of scroll events; ignore them briefly so the button does not reappear.
const suppressDetectionUntilRef = useRef(0)
const publishVisible = useCallback((next: HardScrollUpDetectorState) => {
detectorRef.current = next
if (showScrollToTopRef.current !== next.visible) {
showScrollToTopRef.current = next.visible
setShowScrollToTop(next.visible)
}
}, [])
const clearIdleTimer = useCallback(() => {
if (idleTimerRef.current !== null) {
window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = null
}
}, [])
// Arm once per intent timestamp; do not reset on every scroll tick (that stuck the button).
const armIdleHide = useCallback(
(element: HTMLElement, lastIntentAt: number) => {
clearIdleTimer()
const fireAt = lastIntentAt + HARD_SCROLL_UP.hideAfterIdleMs
const delayMs = Math.max(0, fireAt - window.performance.now())
idleTimerRef.current = window.setTimeout(() => {
idleTimerRef.current = null
const now = window.performance.now()
// Jump-to-top already dismissed; if a stale timer lands in the suppress window, force-hide.
if (now < suppressDetectionUntilRef.current) {
publishVisible(createHardScrollUpDetectorState())
return
}
const viewport = readViewport(element)
const next = reduceHardScrollUpOnIdle(detectorRef.current, {
...viewport,
t: now
})
// Belt-and-suspenders: if still visible past the deadline, force dismiss.
if (next.visible && now - next.lastIntentAt >= HARD_SCROLL_UP.hideAfterIdleMs) {
publishVisible(createHardScrollUpDetectorState())
return
}
publishVisible(next)
}, delayMs)
},
[clearIdleTimer, publishVisible]
)
const applyDetectorResult = useCallback(
(
element: HTMLElement,
previous: HardScrollUpDetectorState,
next: HardScrollUpDetectorState
) => {
publishVisible(next)
if (!next.visible) {
clearIdleTimer()
return
}
// Only re-arm when intent is refreshed; scroll spam must not extend lifetime.
if (next.lastIntentAt !== previous.lastIntentAt) {
armIdleHide(element, next.lastIntentAt)
}
},
[armIdleHide, clearIdleTimer, publishVisible]
)
useEffect(() => {
if (!scrollElement) {
clearIdleTimer()
publishVisible(createHardScrollUpDetectorState())
return
}
const onWheel = (event: WheelEvent): void => {
const now = window.performance.now()
const viewport = readViewport(scrollElement)
// Always allow force-hide even while jump-to-top is suppressing detection.
if (shouldForceHide(viewport)) {
publishVisible(createHardScrollUpDetectorState())
clearIdleTimer()
return
}
if (now < suppressDetectionUntilRef.current) {
return
}
const previous = detectorRef.current
const next = reduceHardScrollUpOnWheel(previous, {
...viewport,
t: now,
deltaY: event.deltaY,
deltaMode: event.deltaMode
})
applyDetectorResult(scrollElement, previous, next)
}
const onScroll = (): void => {
const now = window.performance.now()
const viewport = readViewport(scrollElement)
if (shouldForceHide(viewport)) {
publishVisible(createHardScrollUpDetectorState())
clearIdleTimer()
return
}
if (now < suppressDetectionUntilRef.current) {
return
}
// Scroll events do not expose their origin; only velocity-detect gestures we observed directly.
if (!scrollbarDragRef.current && !touchScrollRef.current) {
return
}
const previous = detectorRef.current
const next = reduceHardScrollUpOnScroll(previous, {
...viewport,
t: now
})
applyDetectorResult(scrollElement, previous, next)
}
const onPointerDown = (event: PointerEvent): void => {
if (event.pointerType === 'touch') {
touchScrollRef.current = true
return
}
const rect = scrollElement.getBoundingClientRect()
const nativeScrollbarWidth = scrollElement.offsetWidth - scrollElement.clientWidth
const scrollbarHitWidth = Math.max(12, nativeScrollbarWidth)
scrollbarDragRef.current =
event.target === scrollElement && event.clientX >= rect.right - scrollbarHitWidth
}
const onPointerEnd = (): void => {
scrollbarDragRef.current = false
touchScrollRef.current = false
}
scrollElement.addEventListener('wheel', onWheel, { passive: true })
scrollElement.addEventListener('scroll', onScroll, { passive: true })
scrollElement.addEventListener('pointerdown', onPointerDown, { passive: true })
window.addEventListener('pointerup', onPointerEnd, { passive: true })
window.addEventListener('pointercancel', onPointerEnd, { passive: true })
return () => {
scrollElement.removeEventListener('wheel', onWheel)
scrollElement.removeEventListener('scroll', onScroll)
scrollElement.removeEventListener('pointerdown', onPointerDown)
window.removeEventListener('pointerup', onPointerEnd)
window.removeEventListener('pointercancel', onPointerEnd)
onPointerEnd()
clearIdleTimer()
}
}, [applyDetectorResult, clearIdleTimer, publishVisible, scrollElement])
const scrollToTop = useCallback(() => {
if (!scrollElement) {
return
}
onUserScrollIntent?.()
publishVisible(reduceHardScrollUpOnDismiss(detectorRef.current))
clearIdleTimer()
suppressDetectionUntilRef.current =
window.performance.now() + HARD_SCROLL_UP.suppressAfterJumpMs
scrollElement.scrollTo({ top: 0, behavior: 'auto' })
scrollElement.focus({ preventScroll: true })
}, [clearIdleTimer, onUserScrollIntent, publishVisible, scrollElement])
return {
showScrollToTop,
scrollToTop
}
}
@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest'
import {
createHardScrollUpDetectorState,
HARD_SCROLL_UP,
normalizeWheelDeltaY,
reduceHardScrollUpOnDismiss,
reduceHardScrollUpOnIdle,
reduceHardScrollUpOnScroll,
reduceHardScrollUpOnWheel
} from './worktree-list-hard-scroll-up'
const DEEP = {
scrollTop: 1200,
maxScroll: 4000
} as const
const SHORT_LIST = {
scrollTop: 200,
maxScroll: 200
} as const
describe('normalizeWheelDeltaY', () => {
it('keeps pixel mode and expands line/page modes', () => {
expect(normalizeWheelDeltaY(-40, 0)).toBe(-40)
expect(normalizeWheelDeltaY(-3, 1)).toBe(-48)
expect(normalizeWheelDeltaY(-1, 2)).toBe(-600)
})
})
describe('reduceHardScrollUpOnWheel', () => {
it('stays hidden for short lists and near-top viewports', () => {
let state = createHardScrollUpDetectorState()
for (let i = 0; i < 10; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
...SHORT_LIST,
t: i * 16,
deltaY: -120
})
}
expect(state.visible).toBe(false)
state = createHardScrollUpDetectorState()
for (let i = 0; i < 10; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
scrollTop: 20,
maxScroll: 4000,
t: i * 16,
deltaY: -120
})
}
expect(state.visible).toBe(false)
})
it('does not show on gentle upward scrolling', () => {
let state = createHardScrollUpDetectorState()
// Small trackpad ticks — effort exists but is not "hard".
for (let i = 0; i < 8; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
t: i * 40,
deltaY: -18
})
}
expect(state.visible).toBe(false)
})
it('shows after a sustained hard upward wheel burst', () => {
let state = createHardScrollUpDetectorState()
// ~5 hard ticks in < window: 5 * 160 = 800 >= hardTotalDeltaPx
for (let i = 0; i < 5; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
scrollTop: DEEP.scrollTop - i * 40,
t: 1000 + i * 40,
deltaY: -160
})
}
expect(state.visible).toBe(true)
expect(state.lastIntentAt).toBe(1000 + 4 * 40)
})
it('shows after a trackpad fling (high peak + enough total)', () => {
let state = createHardScrollUpDetectorState()
const deltas = [-40, -120, -200, -80]
deltas.forEach((deltaY, i) => {
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
t: 2000 + i * 30,
deltaY
})
})
expect(state.visible).toBe(true)
})
it('hides on significant downward scroll', () => {
let state = createHardScrollUpDetectorState()
for (let i = 0; i < 5; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
t: i * 40,
deltaY: -160
})
}
expect(state.visible).toBe(true)
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
t: 500,
deltaY: 80
})
expect(state.visible).toBe(false)
expect(state.wheelSamples).toEqual([])
expect(state.scrollSamples).toEqual([])
})
it('hides after cumulative small downward wheel events', () => {
let state = createHardScrollUpDetectorState()
for (let i = 0; i < 5; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
t: i * 40,
deltaY: -160
})
}
state = reduceHardScrollUpOnWheel(state, { ...DEEP, t: 220, deltaY: 20 })
state = reduceHardScrollUpOnWheel(state, { ...DEEP, t: 260, deltaY: 20 })
expect(state.visible).toBe(true)
expect(state.lastIntentAt).toBe(160)
state = reduceHardScrollUpOnWheel(state, { ...DEEP, t: 300, deltaY: 20 })
expect(state).toEqual(createHardScrollUpDetectorState())
})
it('clears when the user reaches the top', () => {
let state = createHardScrollUpDetectorState()
for (let i = 0; i < 5; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
t: i * 40,
deltaY: -160
})
}
expect(state.visible).toBe(true)
state = reduceHardScrollUpOnWheel(state, {
scrollTop: 10,
maxScroll: 4000,
t: 400,
deltaY: -20
})
expect(state.visible).toBe(false)
})
})
describe('reduceHardScrollUpOnScroll', () => {
it('shows when scrollbar drag velocity is hard upward', () => {
let state = createHardScrollUpDetectorState()
// Drop 500px in 250ms => 2000 px/s
state = reduceHardScrollUpOnScroll(state, {
scrollTop: 1500,
maxScroll: 4000,
t: 0
})
state = reduceHardScrollUpOnScroll(state, {
scrollTop: 1000,
maxScroll: 4000,
t: HARD_SCROLL_UP.velocitySustainMs + 90
})
expect(state.visible).toBe(true)
})
it('ignores slow scrollbar movement', () => {
let state = createHardScrollUpDetectorState()
state = reduceHardScrollUpOnScroll(state, {
scrollTop: 1500,
maxScroll: 4000,
t: 0
})
state = reduceHardScrollUpOnScroll(state, {
scrollTop: 1450,
maxScroll: 4000,
t: 300
})
expect(state.visible).toBe(false)
})
it('hides after cumulative small downward scrollbar movement', () => {
let state = createHardScrollUpDetectorState()
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1500, t: 0 })
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1000, t: 250 })
expect(state.visible).toBe(true)
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1020, t: 280 })
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1040, t: 310 })
expect(state.visible).toBe(true)
expect(state.lastIntentAt).toBe(250)
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1060, t: 340 })
expect(state).toEqual(createHardScrollUpDetectorState())
})
})
describe('reduceHardScrollUpOnIdle / dismiss', () => {
it('auto-hides after idle while still deep', () => {
let state = createHardScrollUpDetectorState()
for (let i = 0; i < 5; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
t: i * 40,
deltaY: -160
})
}
expect(state.visible).toBe(true)
state = reduceHardScrollUpOnIdle(state, {
...DEEP,
t: state.lastIntentAt + HARD_SCROLL_UP.hideAfterIdleMs - 1
})
expect(state.visible).toBe(true)
state = reduceHardScrollUpOnIdle(state, {
...DEEP,
t: state.lastIntentAt + HARD_SCROLL_UP.hideAfterIdleMs
})
expect(state.visible).toBe(false)
})
it('hides on later non-intent scroll after the idle deadline (scroll spam must not extend)', () => {
let state = createHardScrollUpDetectorState()
for (let i = 0; i < 5; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
t: 1000 + i * 40,
deltaY: -160
})
}
const intentAt = state.lastIntentAt
expect(state.visible).toBe(true)
// Tiny non-intent scrolls after the deadline must still clear visibility.
state = reduceHardScrollUpOnScroll(state, {
scrollTop: DEEP.scrollTop - 2,
maxScroll: DEEP.maxScroll,
t: intentAt + HARD_SCROLL_UP.hideAfterIdleMs + 10
})
expect(state.visible).toBe(false)
})
it('dismiss resets state', () => {
let state = createHardScrollUpDetectorState()
for (let i = 0; i < 5; i += 1) {
state = reduceHardScrollUpOnWheel(state, {
...DEEP,
t: i * 40,
deltaY: -160
})
}
state = reduceHardScrollUpOnDismiss(state)
expect(state).toEqual(createHardScrollUpDetectorState())
})
})
@@ -0,0 +1,338 @@
/**
* Detects intentional "hard scroll up" on a long worktree list so we can offer
* a jump-to-top control. Tuned for trackpad wheel streams and scrollbar drags.
*/
export const HARD_SCROLL_UP = {
/** Only samples inside this window contribute to intent. */
windowMs: 480,
/** List must be scrollable by at least this much. */
minScrollablePx: 480,
/** Must be at least this far from the top before the button can appear. */
minDepthPx: 280,
/** Hide once the viewport is this close to the top. */
nearTopPx: 56,
/** Minimum upward wheel samples in the window. */
minUpEvents: 3,
/** Accumulated |deltaY| (px) for a sustained hard-up gesture. */
hardTotalDeltaPx: 720,
/** Peak single-sample |deltaY| that marks a fling/burst. */
burstPeakDeltaPx: 90,
/** Accumulated |deltaY| paired with a burst peak. */
burstTotalDeltaPx: 320,
/** Scroll-position velocity (px/s upward) that counts as hard drag/fling. */
hardVelocityPxPerSec: 1600,
/** How long hard velocity must be sustained. */
velocitySustainMs: 160,
/** Clear intent after idle so the button does not linger forever. */
hideAfterIdleMs: 2600,
/** Significant down-scroll cancels upward intent. */
significantDownDeltaPx: 48,
/** Cap stored samples so long sessions stay cheap. */
maxSamples: 32,
/** Ignore scroll input this long after a programmatic jump to top. */
suppressAfterJumpMs: 120
} as const
export type HardScrollUpWheelSample = {
t: number
/** Positive = toward top (up). Normalized to CSS pixels. */
upDeltaPx: number
}
export type HardScrollUpScrollSample = {
t: number
scrollTop: number
}
export type HardScrollUpDetectorState = {
// Why: wheel + scroll both fire for the same gesture; keep them separate so
// wheel magnitude is not double-counted with scrollTop deltas.
wheelSamples: HardScrollUpWheelSample[]
scrollSamples: HardScrollUpScrollSample[]
wheelDownDeltaPx: number
scrollDownDeltaPx: number
visible: boolean
lastIntentAt: number
}
export type HardScrollUpViewport = {
scrollTop: number
maxScroll: number
t: number
}
export function createHardScrollUpDetectorState(): HardScrollUpDetectorState {
return {
wheelSamples: [],
scrollSamples: [],
wheelDownDeltaPx: 0,
scrollDownDeltaPx: 0,
visible: false,
lastIntentAt: 0
}
}
export function normalizeWheelDeltaY(deltaY: number, deltaMode: number): number {
// WheelEvent.DOM_DELTA_LINE / DOM_DELTA_PAGE — convert to approximate CSS px.
if (deltaMode === 1) {
return deltaY * 16
}
if (deltaMode === 2) {
return deltaY * 600
}
return deltaY
}
function pruneByTime<T extends { t: number }>(samples: readonly T[], t: number): T[] {
const cutoff = t - HARD_SCROLL_UP.windowMs
const pruned = samples.filter((sample) => sample.t >= cutoff)
if (pruned.length <= HARD_SCROLL_UP.maxSamples) {
return pruned
}
return pruned.slice(pruned.length - HARD_SCROLL_UP.maxSamples)
}
function isListLongEnough(maxScroll: number): boolean {
return maxScroll >= HARD_SCROLL_UP.minScrollablePx
}
function isDeepEnough(scrollTop: number): boolean {
return scrollTop >= HARD_SCROLL_UP.minDepthPx
}
function isNearTop(scrollTop: number): boolean {
return scrollTop <= HARD_SCROLL_UP.nearTopPx
}
function computeWheelIntent(samples: readonly HardScrollUpWheelSample[]): boolean {
const upSamples = samples.filter((sample) => sample.upDeltaPx > 0)
if (upSamples.length < HARD_SCROLL_UP.minUpEvents) {
return false
}
let totalUp = 0
let peakUp = 0
for (const sample of upSamples) {
totalUp += sample.upDeltaPx
if (sample.upDeltaPx > peakUp) {
peakUp = sample.upDeltaPx
}
}
if (totalUp >= HARD_SCROLL_UP.hardTotalDeltaPx) {
return true
}
return peakUp >= HARD_SCROLL_UP.burstPeakDeltaPx && totalUp >= HARD_SCROLL_UP.burstTotalDeltaPx
}
function computeVelocityIntent(samples: readonly HardScrollUpScrollSample[], t: number): boolean {
if (samples.length < 2) {
return false
}
const newest = samples.at(-1)
if (!newest || t - newest.t > HARD_SCROLL_UP.velocitySustainMs) {
return false
}
// Walk backward to the oldest sample still inside the window, then measure
// average upward velocity across that span.
let oldest = newest
for (let i = samples.length - 2; i >= 0; i -= 1) {
const sample = samples.at(i)
if (!sample) {
continue
}
if (newest.t - sample.t > HARD_SCROLL_UP.windowMs) {
break
}
oldest = sample
}
const elapsedMs = newest.t - oldest.t
if (elapsedMs < HARD_SCROLL_UP.velocitySustainMs) {
return false
}
const upwardPx = oldest.scrollTop - newest.scrollTop
if (upwardPx <= 0) {
return false
}
const velocity = (upwardPx / elapsedMs) * 1000
return velocity >= HARD_SCROLL_UP.hardVelocityPxPerSec
}
function withVisibility(
state: HardScrollUpDetectorState,
{ t, scrollTop, maxScroll, intent }: HardScrollUpViewport & { intent: boolean }
): HardScrollUpDetectorState {
if (!isListLongEnough(maxScroll) || isNearTop(scrollTop)) {
if (
!state.visible &&
state.wheelSamples.length === 0 &&
state.scrollSamples.length === 0 &&
state.wheelDownDeltaPx === 0 &&
state.scrollDownDeltaPx === 0 &&
state.lastIntentAt === 0
) {
return state
}
return createHardScrollUpDetectorState()
}
if (intent && isDeepEnough(scrollTop)) {
return {
...state,
visible: true,
lastIntentAt: t
}
}
// Why: hide on the deadline itself even if only non-intent scroll noise arrives;
// the hook also arms a timer, but event-driven hide must not depend on timer re-arms.
if (state.visible && t - state.lastIntentAt >= HARD_SCROLL_UP.hideAfterIdleMs) {
return createHardScrollUpDetectorState()
}
return state
}
/**
* Ingest a wheel event. `deltaY` follows the browser convention (negative = up).
*/
export function reduceHardScrollUpOnWheel(
state: HardScrollUpDetectorState,
{
deltaY,
deltaMode = 0,
scrollTop,
maxScroll,
t
}: HardScrollUpViewport & { deltaY: number; deltaMode?: number }
): HardScrollUpDetectorState {
if (!isListLongEnough(maxScroll)) {
return createHardScrollUpDetectorState()
}
if (isNearTop(scrollTop)) {
return withVisibility(state, { t, scrollTop, maxScroll, intent: false })
}
const pixelDeltaY = normalizeWheelDeltaY(deltaY, deltaMode)
// Browser: negative deltaY = scroll up (content moves down, viewport toward top).
const upDeltaPx = -pixelDeltaY
const wheelDownDeltaPx =
upDeltaPx < 0
? state.wheelDownDeltaPx + Math.abs(upDeltaPx)
: upDeltaPx > 0
? 0
: state.wheelDownDeltaPx
if (wheelDownDeltaPx >= HARD_SCROLL_UP.significantDownDeltaPx) {
return createHardScrollUpDetectorState()
}
const nextWheelSamples = pruneByTime(
upDeltaPx > 0 ? [...state.wheelSamples, { t, upDeltaPx }] : [],
t
)
const nextScrollSamples = pruneByTime(state.scrollSamples, t)
const intent = upDeltaPx > 0 && computeWheelIntent(nextWheelSamples)
return withVisibility(
{
...state,
wheelSamples: nextWheelSamples,
scrollSamples: nextScrollSamples,
wheelDownDeltaPx
},
{ t, scrollTop, maxScroll, intent }
)
}
/**
* Ingest a scroll position sample from an active scrollbar/touch gesture.
* Velocity-based; ignores tiny no-op scrolls.
*/
export function reduceHardScrollUpOnScroll(
state: HardScrollUpDetectorState,
{ scrollTop, maxScroll, t }: HardScrollUpViewport
): HardScrollUpDetectorState {
if (!isListLongEnough(maxScroll)) {
return createHardScrollUpDetectorState()
}
if (isNearTop(scrollTop)) {
return withVisibility(state, { t, scrollTop, maxScroll, intent: false })
}
const last = state.scrollSamples.at(-1)
const downDeltaPx = last ? Math.max(0, scrollTop - last.scrollTop) : 0
const upDeltaPx = last ? Math.max(0, last.scrollTop - scrollTop) : 0
const scrollDownDeltaPx =
downDeltaPx > 0
? state.scrollDownDeltaPx + downDeltaPx
: upDeltaPx > 0
? 0
: state.scrollDownDeltaPx
if (scrollDownDeltaPx >= HARD_SCROLL_UP.significantDownDeltaPx) {
return createHardScrollUpDetectorState()
}
// Ignore pure no-ops so a stalled scrollbar does not pad the window.
if (last && last.scrollTop === scrollTop && t - last.t < 16) {
return withVisibility(state, {
t,
scrollTop,
maxScroll,
intent: false
})
}
const nextScrollSamples = pruneByTime(
[...(downDeltaPx > 0 ? [] : state.scrollSamples), { t, scrollTop }],
t
)
const nextWheelSamples = pruneByTime(state.wheelSamples, t)
const intent = upDeltaPx > 0 && computeVelocityIntent(nextScrollSamples, t)
return withVisibility(
{
...state,
wheelSamples: nextWheelSamples,
scrollSamples: nextScrollSamples,
scrollDownDeltaPx
},
{ t, scrollTop, maxScroll, intent }
)
}
/** Idle tick so the button auto-hides without requiring more input. */
export function reduceHardScrollUpOnIdle(
state: HardScrollUpDetectorState,
{ scrollTop, maxScroll, t }: HardScrollUpViewport
): HardScrollUpDetectorState {
if (!state.visible) {
return state
}
return withVisibility(state, { t, scrollTop, maxScroll, intent: false })
}
export function reduceHardScrollUpOnDismiss(
state: HardScrollUpDetectorState
): HardScrollUpDetectorState {
if (
!state.visible &&
state.wheelSamples.length === 0 &&
state.scrollSamples.length === 0 &&
state.wheelDownDeltaPx === 0 &&
state.scrollDownDeltaPx === 0
) {
return state
}
return createHardScrollUpDetectorState()
}
+3
View File
@@ -5386,6 +5386,9 @@
"a0f9863597": "Force Delete {{count}} Branches",
"a0f9863597_one": "Force Delete {{count}} Branch",
"a0f9863597_other": "Force Delete {{count}} Branches"
},
"WorktreeListScrollToTopButton": {
"jumpToTop": "Jump to top"
}
},
"shared": {