fix(tui): keep streaming progress stable during interaction
This commit is contained in:
parent
1c964ed43f
commit
355e0ae960
@ -29,7 +29,7 @@ import {
|
||||
FOCUS_IN,
|
||||
FOCUS_OUT
|
||||
} from '../termio/csi.js'
|
||||
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js'
|
||||
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, SHOW_CURSOR } from '../termio/dec.js'
|
||||
|
||||
import AppContext from './AppContext.js'
|
||||
import { ClockProvider } from './ClockContext.js'
|
||||
@ -206,10 +206,9 @@ export default class App extends PureComponent<Props, State> {
|
||||
)
|
||||
}
|
||||
override componentDidMount() {
|
||||
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
|
||||
if (this.props.stdout.isTTY) {
|
||||
this.props.stdout.write(HIDE_CURSOR)
|
||||
}
|
||||
// Keep the native terminal cursor visible. Ink parks it at the declared
|
||||
// input caret after each frame, so the terminal emulator provides the
|
||||
// normal blinking block/bar without React-driven blink re-renders.
|
||||
}
|
||||
override componentWillUnmount() {
|
||||
if (this.props.stdout.isTTY) {
|
||||
@ -470,7 +469,7 @@ export default class App extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
if (this.props.stdout.isTTY) {
|
||||
this.props.stdout.write(HIDE_CURSOR + EFE)
|
||||
this.props.stdout.write(EFE)
|
||||
}
|
||||
|
||||
this.inputEmitter.emit('resume')
|
||||
@ -569,18 +568,19 @@ function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined,
|
||||
|
||||
/** Exported for testing. Mutates app.props.selection and click/hover state. */
|
||||
export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
||||
// Allow disabling click handling while keeping wheel scroll (which goes
|
||||
// through the keybinding system as 'wheelup'/'wheeldown', not here).
|
||||
if (isMouseClicksDisabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const sel = app.props.selection
|
||||
// Terminal coords are 1-indexed; screen buffer is 0-indexed
|
||||
const col = m.col - 1
|
||||
const row = m.row - 1
|
||||
const baseButton = m.button & 0x03
|
||||
|
||||
// Allow disabling app click/selection handling while keeping wheel scroll
|
||||
// and DOM mouse dispatch alive. Put this after coordinate/button decoding
|
||||
// and exempt non-left buttons so scrollbar/right-click handlers still work.
|
||||
if (isMouseClicksDisabled() && baseButton === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (m.action === 'press') {
|
||||
if ((m.button & 0x20) !== 0 && baseButton === 3) {
|
||||
if (app.mouseCaptureTarget) {
|
||||
|
||||
@ -122,6 +122,19 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
|
||||
})
|
||||
}
|
||||
|
||||
const scrollByNow = (dy: number) => {
|
||||
const el = domRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
el.stickyScroll = false
|
||||
el.scrollAnchor = undefined
|
||||
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
|
||||
scrollMutated(el)
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): ScrollBoxHandle => ({
|
||||
@ -155,22 +168,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
|
||||
}
|
||||
scrollMutated(box)
|
||||
},
|
||||
scrollBy(dy: number) {
|
||||
const el = domRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
el.stickyScroll = false
|
||||
// Wheel input cancels any in-flight anchor seek — user override.
|
||||
el.scrollAnchor = undefined
|
||||
// Accumulate in pendingScrollDelta; renderer drains it at a capped
|
||||
// rate so fast flicks show intermediate frames. Pure accumulator:
|
||||
// scroll-up followed by scroll-down naturally cancels.
|
||||
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
|
||||
scrollMutated(el)
|
||||
},
|
||||
scrollBy: scrollByNow,
|
||||
scrollToBottom() {
|
||||
const el = domRef.current
|
||||
|
||||
|
||||
28
ui-tui/src/__tests__/interactionMode.test.ts
Normal file
28
ui-tui/src/__tests__/interactionMode.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getInteractionMode, markScrolling, markTyping, resetInteractionMode } from '../app/interactionMode.js'
|
||||
import { SCROLLING_IDLE_MS, TYPING_IDLE_MS } from '../config/timing.js'
|
||||
|
||||
describe('interactionMode', () => {
|
||||
afterEach(() => {
|
||||
resetInteractionMode()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('holds scrolling mode briefly then returns idle', () => {
|
||||
vi.useFakeTimers()
|
||||
markScrolling()
|
||||
expect(getInteractionMode()).toBe('scrolling')
|
||||
vi.advanceTimersByTime(SCROLLING_IDLE_MS)
|
||||
expect(getInteractionMode()).toBe('idle')
|
||||
})
|
||||
|
||||
it('typing takes priority over scrolling', () => {
|
||||
vi.useFakeTimers()
|
||||
markTyping()
|
||||
markScrolling()
|
||||
expect(getInteractionMode()).toBe('typing')
|
||||
vi.advanceTimersByTime(TYPING_IDLE_MS)
|
||||
expect(getInteractionMode()).toBe('idle')
|
||||
})
|
||||
})
|
||||
53
ui-tui/src/__tests__/scroll.test.ts
Normal file
53
ui-tui/src/__tests__/scroll.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { scrollWithSelectionBy } from '../app/scroll.js'
|
||||
|
||||
function makeScroll(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
getPendingDelta: vi.fn(() => 0),
|
||||
getScrollHeight: vi.fn(() => 100),
|
||||
getScrollTop: vi.fn(() => 10),
|
||||
getViewportHeight: vi.fn(() => 20),
|
||||
getViewportTop: vi.fn(() => 0),
|
||||
scrollBy: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('scrollWithSelectionBy', () => {
|
||||
it('clamps to the actual remaining scroll distance before calling scrollBy', () => {
|
||||
const s = makeScroll({
|
||||
getScrollHeight: vi.fn(() => 30),
|
||||
getScrollTop: vi.fn(() => 9),
|
||||
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(1)
|
||||
})
|
||||
|
||||
it('does nothing at the edge instead of queueing dead pending deltas', () => {
|
||||
const s = makeScroll({
|
||||
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).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
13
ui-tui/src/__tests__/virtualHistoryClamp.test.ts
Normal file
13
ui-tui/src/__tests__/virtualHistoryClamp.test.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { shouldSetVirtualClamp } from '../hooks/useVirtualHistory.js'
|
||||
|
||||
describe('virtual history clamp bounds', () => {
|
||||
it('does not clamp sticky live tail content', () => {
|
||||
expect(shouldSetVirtualClamp({ itemCount: 20, sticky: true, viewportHeight: 10 })).toBe(false)
|
||||
})
|
||||
|
||||
it('sets clamp bounds after manual scroll breaks sticky mode', () => {
|
||||
expect(shouldSetVirtualClamp({ itemCount: 20, sticky: false, viewportHeight: 10 })).toBe(true)
|
||||
})
|
||||
})
|
||||
52
ui-tui/src/app/interactionMode.ts
Normal file
52
ui-tui/src/app/interactionMode.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { SCROLLING_IDLE_MS, TYPING_IDLE_MS } from '../config/timing.js'
|
||||
|
||||
export type InteractionMode = 'idle' | 'scrolling' | 'typing'
|
||||
|
||||
type Timer = null | ReturnType<typeof setTimeout>
|
||||
|
||||
let mode: InteractionMode = 'idle'
|
||||
let scrollingTimer: Timer = null
|
||||
let typingTimer: Timer = null
|
||||
|
||||
const clear = (t: Timer): null => {
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getInteractionMode(): InteractionMode {
|
||||
return mode
|
||||
}
|
||||
|
||||
export function markTyping(): void {
|
||||
mode = 'typing'
|
||||
typingTimer = clear(typingTimer)
|
||||
scrollingTimer = clear(scrollingTimer)
|
||||
typingTimer = setTimeout(() => {
|
||||
typingTimer = null
|
||||
mode = 'idle'
|
||||
}, TYPING_IDLE_MS)
|
||||
}
|
||||
|
||||
export function markScrolling(): void {
|
||||
if (mode === 'typing') {
|
||||
return
|
||||
}
|
||||
|
||||
mode = 'scrolling'
|
||||
scrollingTimer = clear(scrollingTimer)
|
||||
scrollingTimer = setTimeout(() => {
|
||||
scrollingTimer = null
|
||||
if (mode === 'scrolling') {
|
||||
mode = 'idle'
|
||||
}
|
||||
}, SCROLLING_IDLE_MS)
|
||||
}
|
||||
|
||||
export function resetInteractionMode(): void {
|
||||
scrollingTimer = clear(scrollingTimer)
|
||||
typingTimer = clear(typingTimer)
|
||||
mode = 'idle'
|
||||
}
|
||||
@ -31,8 +31,12 @@ export interface StateSetter<T> {
|
||||
export type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||
|
||||
export interface SelectionApi {
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
clearSelection: () => void
|
||||
copySelection: () => string
|
||||
getState: () => unknown
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
}
|
||||
|
||||
export interface CompletionItem {
|
||||
|
||||
58
ui-tui/src/app/scroll.ts
Normal file
58
ui-tui/src/app/scroll.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||
|
||||
import type { SelectionApi } from './interfaces.js'
|
||||
import { markScrolling } from './interactionMode.js'
|
||||
|
||||
export interface SelectionSnap {
|
||||
anchor?: { row: number } | null
|
||||
focus?: { row: number } | null
|
||||
isDragging?: boolean
|
||||
}
|
||||
|
||||
export interface ScrollWithSelectionOptions {
|
||||
readonly scrollRef: { readonly current: ScrollBoxHandle | null }
|
||||
readonly selection: SelectionApi
|
||||
}
|
||||
|
||||
export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: ScrollWithSelectionOptions): void {
|
||||
const s = scrollRef.current
|
||||
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
|
||||
const cur = s.getScrollTop() + s.getPendingDelta()
|
||||
const viewport = Math.max(0, s.getViewportHeight())
|
||||
const max = Math.max(0, s.getScrollHeight() - viewport)
|
||||
const actual = Math.max(0, Math.min(max, cur + delta)) - cur
|
||||
|
||||
if (actual === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
markScrolling()
|
||||
|
||||
const sel = selection.getState() as null | SelectionSnap
|
||||
const top = s.getViewportTop()
|
||||
const bottom = top + viewport - 1
|
||||
|
||||
if (
|
||||
sel?.anchor &&
|
||||
sel.focus &&
|
||||
sel.anchor.row >= top &&
|
||||
sel.anchor.row <= bottom &&
|
||||
(sel.isDragging || (sel.focus.row >= top && sel.focus.row <= bottom))
|
||||
) {
|
||||
const shift = sel.isDragging ? selection.shiftAnchor : selection.shiftSelection
|
||||
|
||||
if (actual > 0) {
|
||||
selection.captureScrolledRows(top, top + actual - 1, 'above')
|
||||
} else {
|
||||
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
|
||||
}
|
||||
|
||||
shift(-actual, top, bottom)
|
||||
}
|
||||
|
||||
s.scrollBy(actual)
|
||||
}
|
||||
@ -1,4 +1,10 @@
|
||||
import { REASONING_PULSE_MS, STREAM_BATCH_MS, STREAM_IDLE_BATCH_MS, STREAM_TYPING_BATCH_MS } from '../config/timing.js'
|
||||
import {
|
||||
REASONING_PULSE_MS,
|
||||
STREAM_BATCH_MS,
|
||||
STREAM_IDLE_BATCH_MS,
|
||||
STREAM_SCROLLING_BATCH_MS,
|
||||
STREAM_TYPING_BATCH_MS
|
||||
} from '../config/timing.js'
|
||||
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
|
||||
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
|
||||
import {
|
||||
@ -10,6 +16,7 @@ import {
|
||||
} from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
||||
|
||||
import { getInteractionMode } from './interactionMode.js'
|
||||
import { resetFlowOverlays } from './overlayStore.js'
|
||||
import { pushSnapshot } from './spawnHistoryStore.js'
|
||||
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
||||
@ -497,12 +504,15 @@ class TurnController {
|
||||
return
|
||||
}
|
||||
|
||||
const interaction = getInteractionMode()
|
||||
const delay = interaction === 'scrolling' ? STREAM_SCROLLING_BATCH_MS : interaction === 'typing' ? STREAM_TYPING_BATCH_MS : this.streamDelay
|
||||
|
||||
this.streamTimer = setTimeout(() => {
|
||||
this.streamTimer = null
|
||||
const raw = this.bufRef.trimStart()
|
||||
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
|
||||
patchTurnState({ streaming: visible })
|
||||
}, this.streamDelay)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
startMessage() {
|
||||
|
||||
@ -33,6 +33,7 @@ import { useComposerState } from './useComposerState.js'
|
||||
import { useConfigSync } from './useConfigSync.js'
|
||||
import { useInputHandlers } from './useInputHandlers.js'
|
||||
import { useLongRunToolCharms } from './useLongRunToolCharms.js'
|
||||
import { scrollWithSelectionBy } from './scroll.js'
|
||||
import { useSessionLifecycle } from './useSessionLifecycle.js'
|
||||
import { useSubmission } from './useSubmission.js'
|
||||
|
||||
@ -64,12 +65,6 @@ const statusColorOf = (status: string, t: { dim: string; error: string; ok: stri
|
||||
return t.dim
|
||||
}
|
||||
|
||||
interface SelectionSnap {
|
||||
anchor?: { row: number }
|
||||
focus?: { row: number }
|
||||
isDragging?: boolean
|
||||
}
|
||||
|
||||
export function useMainApp(gw: GatewayClient) {
|
||||
const { exit } = useApp()
|
||||
const { stdout } = useStdout()
|
||||
@ -186,46 +181,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols)
|
||||
|
||||
const scrollWithSelection = useCallback(
|
||||
(delta: number) => {
|
||||
const s = scrollRef.current
|
||||
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
|
||||
const sel = selection.getState() as null | SelectionSnap
|
||||
const top = s.getViewportTop()
|
||||
const bottom = top + s.getViewportHeight() - 1
|
||||
|
||||
if (
|
||||
!sel?.anchor ||
|
||||
!sel.focus ||
|
||||
sel.anchor.row < top ||
|
||||
sel.anchor.row > bottom ||
|
||||
(!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom))
|
||||
) {
|
||||
return s.scrollBy(delta)
|
||||
}
|
||||
|
||||
const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight())
|
||||
const cur = s.getScrollTop() + s.getPendingDelta()
|
||||
const actual = Math.max(0, Math.min(max, cur + delta)) - cur
|
||||
|
||||
if (actual === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const shift = sel!.isDragging ? selection.shiftAnchor : selection.shiftSelection
|
||||
|
||||
if (actual > 0) {
|
||||
selection.captureScrolledRows(top, top + actual - 1, 'above')
|
||||
} else {
|
||||
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
|
||||
}
|
||||
|
||||
shift(-actual, top, bottom)
|
||||
s.scrollBy(delta)
|
||||
},
|
||||
(delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }),
|
||||
[selection]
|
||||
)
|
||||
|
||||
@ -700,14 +656,12 @@ export function useMainApp(gw: GatewayClient) {
|
||||
[turn, showProgressArea]
|
||||
)
|
||||
|
||||
const frozenProgressRef = useRef(liveProgress)
|
||||
|
||||
// Freeze the offscreen live tail so scroll doesn't rebuild unseen streaming UI.
|
||||
if (liveTailVisible || !ui.busy) {
|
||||
frozenProgressRef.current = liveProgress
|
||||
}
|
||||
|
||||
const appProgress = liveTailVisible || !ui.busy ? liveProgress : frozenProgressRef.current
|
||||
// Always pass current progress through. Freezing this while offscreen looked
|
||||
// like a nice scroll optimization, but it also froze the live tail's
|
||||
// thinking/tool state at arbitrary intermediate snapshots. Streaming update
|
||||
// throttling now handles interaction load; progress state should remain
|
||||
// truthful so panels don't randomly disappear.
|
||||
const appProgress = liveProgress
|
||||
|
||||
const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd()
|
||||
const gitBranch = useGitBranch(cwd)
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { TYPING_IDLE_MS } from '../config/timing.js'
|
||||
import { attachedImageNotice } from '../domain/messages.js'
|
||||
import { looksLikeSlashCommand } from '../domain/slash.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
@ -11,6 +10,7 @@ import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js'
|
||||
import { markTyping } from './interactionMode.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
@ -48,28 +48,13 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
} = opts
|
||||
|
||||
const lastEmptyAt = useRef(0)
|
||||
const typingIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (composerState.input || composerState.inputBuf.length) {
|
||||
markTyping()
|
||||
if (getUiState().busy) {
|
||||
turnController.boostStreamingForTyping()
|
||||
}
|
||||
|
||||
if (typingIdleTimer.current) {
|
||||
clearTimeout(typingIdleTimer.current)
|
||||
}
|
||||
|
||||
typingIdleTimer.current = setTimeout(() => {
|
||||
typingIdleTimer.current = null
|
||||
turnController.relaxStreaming()
|
||||
}, TYPING_IDLE_MS)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typingIdleTimer.current) {
|
||||
clearTimeout(typingIdleTimer.current)
|
||||
}
|
||||
}
|
||||
}, [composerState.input, composerState.inputBuf])
|
||||
|
||||
|
||||
@ -336,7 +336,7 @@ export function TextInput({
|
||||
active: focus && termFocus && !selected
|
||||
})
|
||||
|
||||
const nativeCursor = focus && termFocus && !selected
|
||||
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
if (!focus) {
|
||||
|
||||
@ -2,4 +2,4 @@ export const LARGE_PASTE = { chars: 8000, lines: 80 }
|
||||
export const LONG_MSG = 300
|
||||
export const MAX_HISTORY = 800
|
||||
export const THINKING_COT_MAX = 160
|
||||
export const WHEEL_SCROLL_STEP = 3
|
||||
export const WHEEL_SCROLL_STEP = 6
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
export const STREAM_BATCH_MS = 16
|
||||
export const STREAM_IDLE_BATCH_MS = 16
|
||||
export const STREAM_TYPING_BATCH_MS = 80
|
||||
export const TYPING_IDLE_MS = 120
|
||||
export const STREAM_SCROLLING_BATCH_MS = 250
|
||||
export const STREAM_TYPING_BATCH_MS = 120
|
||||
export const TYPING_IDLE_MS = 250
|
||||
export const SCROLLING_IDLE_MS = 450
|
||||
export const REASONING_PULSE_MS = 700
|
||||
|
||||
@ -17,6 +17,16 @@ const COLD_START = 40
|
||||
const QUANTUM = OVERSCAN >> 1
|
||||
const FREEZE_RENDERS = 2
|
||||
|
||||
export const shouldSetVirtualClamp = ({
|
||||
itemCount,
|
||||
sticky,
|
||||
viewportHeight
|
||||
}: {
|
||||
itemCount: number
|
||||
sticky: boolean
|
||||
viewportHeight: number
|
||||
}) => itemCount > 0 && viewportHeight > 0 && !sticky
|
||||
|
||||
const upperBound = (arr: number[], target: number) => {
|
||||
let lo = 0
|
||||
let hi = arr.length
|
||||
@ -173,11 +183,16 @@ export function useVirtualHistory(
|
||||
// Give the renderer the mounted-row coverage for passive scroll clamping.
|
||||
// Without this, burst wheel/page scroll can race past the React commit that
|
||||
// updates the virtual range and paint spacer-only frames.
|
||||
if (s && n > 0 && vp > 0) {
|
||||
if (s && shouldSetVirtualClamp({ itemCount: n, sticky, viewportHeight: vp })) {
|
||||
const min = offsets[start] ?? 0
|
||||
const max = Math.max(min, (offsets[end] ?? total) - vp)
|
||||
s.setClampBounds(min, max)
|
||||
} else {
|
||||
// Sticky bottom often has live, non-virtualized tail content after the
|
||||
// virtual transcript (streaming answer / thinking / tools). A clamp based
|
||||
// only on virtual history would cap rendering before that tail and make
|
||||
// live thinking appear to vanish. No burst-scroll clamp is needed while
|
||||
// sticky anyway.
|
||||
s?.setClampBounds(undefined, undefined)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user