Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion ui-tui/src/__tests__/viewportStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { getViewportSnapshot, viewportSnapshotKey } from '../lib/viewportStore.js'
import { getScrollbarSnapshot, getViewportSnapshot, scrollbarSnapshotKey, viewportSnapshotKey } from '../lib/viewportStore.js'

describe('viewportStore', () => {
it('normalizes absent scroll handles', () => {
Expand Down Expand Up @@ -51,4 +51,35 @@ describe('viewportStore', () => {
expect(snap.atBottom).toBe(true)
expect(snap.scrollHeight).toBe(20)
})

it('keeps scrollbar position tied to committed scrollTop, not pending target', () => {
const handle = {
getPendingDelta: () => 24,
getScrollHeight: () => 100,
getScrollTop: () => 10,
getViewportHeight: () => 20,
isSticky: () => false
}

const viewport = getViewportSnapshot(handle as any)
const scrollbar = getScrollbarSnapshot(handle as any)

expect(viewport.top).toBe(34)
expect(scrollbar).toEqual({
scrollHeight: 100,
top: 10,
viewportHeight: 20
})
expect(scrollbarSnapshotKey(scrollbar)).toBe('10:20:100')
})

it('clamps scrollbar position to committed scroll bounds', () => {
const handle = {
getScrollHeight: () => 30,
getScrollTop: () => 50,
getViewportHeight: () => 20
}

expect(getScrollbarSnapshot(handle as any).top).toBe(10)
})
})
16 changes: 11 additions & 5 deletions ui-tui/src/components/appChrome.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
import unicodeSpinners from 'unicode-animations'

import { $delegationState } from '../app/delegationStore.js'
Expand All @@ -13,7 +13,7 @@ import { fmtDuration } from '../domain/messages.js'
import { stickyPromptFromViewport } from '../domain/viewport.js'
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
import { fmtK } from '../lib/text.js'
import { useViewportSnapshot } from '../lib/viewportStore.js'
import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'

Expand Down Expand Up @@ -377,7 +377,8 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }:
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<number | null>(null)
const { scrollHeight: total, top: pos, viewportHeight: vp } = useViewportSnapshot(scrollRef)
const grabRef = useRef<number | null>(null)
const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef)

if (!vp) {
return <Box width={1} />
Expand Down Expand Up @@ -405,15 +406,20 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
onMouseDown={(e: { localRow?: number }) => {
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)

grabRef.current = off
setGrab(off)
jump(row, off)
}}
onMouseDrag={(e: { localRow?: number }) =>
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2))
}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseUp={() => setGrab(null)}
onMouseUp={() => {
grabRef.current = null
setGrab(null)
}}
width={1}
>
{!scrollable ? (
Expand Down
50 changes: 50 additions & 0 deletions ui-tui/src/lib/viewportStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export interface ViewportSnapshot {
viewportHeight: number
}

export interface ScrollbarSnapshot {
scrollHeight: number
top: number
viewportHeight: number
}

const EMPTY: ViewportSnapshot = {
atBottom: true,
bottom: 0,
Expand All @@ -20,6 +26,12 @@ const EMPTY: ViewportSnapshot = {
viewportHeight: 0
}

const EMPTY_SCROLLBAR: ScrollbarSnapshot = {
scrollHeight: 0,
top: 0,
viewportHeight: 0
}

export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
if (!s) {
return EMPTY
Expand Down Expand Up @@ -52,6 +64,26 @@ export function viewportSnapshotKey(v: ViewportSnapshot) {
return `${v.atBottom ? 1 : 0}:${Math.ceil(v.top / 8) * 8}:${v.viewportHeight}:${Math.ceil(v.scrollHeight / 8) * 8}:${v.pending}`
}

export function getScrollbarSnapshot(s?: ScrollBoxHandle | null): ScrollbarSnapshot {
if (!s) {
return EMPTY_SCROLLBAR
}

const viewportHeight = Math.max(0, s.getViewportHeight())
const scrollHeight = Math.max(viewportHeight, s.getScrollHeight())
const maxTop = Math.max(0, scrollHeight - viewportHeight)

return {
scrollHeight,
top: Math.max(0, Math.min(maxTop, s.getScrollTop())),
viewportHeight
}
}

export function scrollbarSnapshotKey(v: ScrollbarSnapshot) {
return `${v.top}:${v.viewportHeight}:${v.scrollHeight}`
}

export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ViewportSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
Expand All @@ -72,3 +104,21 @@ export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>
}
}, [key])
}

export function useScrollbarSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ScrollbarSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => scrollbarSnapshotKey(getScrollbarSnapshot(scrollRef.current)),
() => scrollbarSnapshotKey(EMPTY_SCROLLBAR)
)

return useMemo(() => {
const [top = '0', viewportHeight = '0', scrollHeight = '0'] = key.split(':')

return {
scrollHeight: Number(scrollHeight),
top: Number(top),
viewportHeight: Number(viewportHeight)
}
}, [key])
}
Loading