diff --git a/ui-tui/src/__tests__/scroll.test.ts b/ui-tui/src/__tests__/scroll.test.ts index 652cca0973..b9bbdb5fea 100644 --- a/ui-tui/src/__tests__/scroll.test.ts +++ b/ui-tui/src/__tests__/scroll.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it, vi } from 'vitest' import { scrollWithSelectionBy } from '../app/scroll.js' function makeScroll(overrides: Partial> = {}) { + const getScrollHeight = (overrides.getScrollHeight as (() => number) | undefined) ?? vi.fn(() => 100) + return { + getFreshScrollHeight: vi.fn(() => getScrollHeight()), getPendingDelta: vi.fn(() => 0), - getScrollHeight: vi.fn(() => 100), + getScrollHeight, getScrollTop: vi.fn(() => 10), getViewportHeight: vi.fn(() => 20), getViewportTop: vi.fn(() => 0), @@ -34,6 +37,47 @@ describe('scrollWithSelectionBy', () => { expect(s.scrollBy).toHaveBeenCalledWith(1) }) + it('uses fresh scroll height when cached height would swallow a down-scroll at a fake bottom', () => { + const s = makeScroll({ + getFreshScrollHeight: vi.fn(() => 34), + getScrollHeight: vi.fn(() => 30), + getScrollTop: vi.fn(() => 10), + getViewportHeight: vi.fn(() => 20) + }) + + const selection = { + captureScrolledRows: vi.fn(), + getState: vi.fn(() => null), + shiftAnchor: vi.fn(), + shiftSelection: vi.fn() + } + + scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection }) + + expect(s.scrollBy).toHaveBeenCalledWith(4) + }) + + it('uses fresh height when pending down-scroll reaches the cached fake bottom', () => { + const s = makeScroll({ + getFreshScrollHeight: vi.fn(() => 38), + getPendingDelta: vi.fn(() => 2), + getScrollHeight: vi.fn(() => 32), + getScrollTop: vi.fn(() => 10), + getViewportHeight: vi.fn(() => 20) + }) + + const selection = { + captureScrolledRows: vi.fn(), + getState: vi.fn(() => null), + shiftAnchor: vi.fn(), + shiftSelection: vi.fn() + } + + scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection }) + + expect(s.scrollBy).toHaveBeenCalledWith(6) + }) + it('does nothing at the edge instead of queueing dead pending deltas', () => { const s = makeScroll({ getScrollHeight: vi.fn(() => 30), diff --git a/ui-tui/src/app/scroll.ts b/ui-tui/src/app/scroll.ts index 0d736d2c87..e3a53734a3 100644 --- a/ui-tui/src/app/scroll.ts +++ b/ui-tui/src/app/scroll.ts @@ -13,6 +13,23 @@ export interface ScrollWithSelectionOptions { readonly selection: SelectionApi } +function scrollBoundsForDelta(s: ScrollBoxHandle, cur: number, delta: number) { + const viewport = Math.max(0, s.getViewportHeight()) + const cachedHeight = Math.max(viewport, s.getScrollHeight()) + let max = Math.max(0, cachedHeight - viewport) + + // getScrollHeight() is render-time cached. After the streaming tail is + // committed into virtual history, the Yoga height can be fresher than the + // cached value; if we clamp only against the cached fake bottom, wheel-down + // becomes a no-op and no render is scheduled to reveal the real tail. + if (delta > 0 && cur + delta >= max - 1) { + const freshHeight = Math.max(viewport, s.getFreshScrollHeight()) + max = Math.max(0, freshHeight - viewport) + } + + return { max, viewport } +} + export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: ScrollWithSelectionOptions): void { const s = scrollRef.current @@ -21,8 +38,7 @@ export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: S } const cur = s.getScrollTop() + s.getPendingDelta() - const viewport = Math.max(0, s.getViewportHeight()) - const max = Math.max(0, s.getScrollHeight() - viewport) + const { max, viewport } = scrollBoundsForDelta(s, cur, delta) const actual = Math.max(0, Math.min(max, cur + delta)) - cur if (actual === 0) {