Skip to content

Commit fb96539

Browse files
committed
perf(tui): improve scroll performance and pagination in log viewer
Increase buffer multiplier from 1.2x to 3x for smoother backward pagination Implement scroll lock mechanism during content prepend to prevent flickering Add preloading when user is 15 lines from top for better user experience
1 parent 1a6b989 commit fb96539

2 files changed

Lines changed: 53 additions & 6 deletions

File tree

src/cli/tui/routes/workflow/components/output/output-window.tsx

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export function OutputWindow(props: OutputWindowProps) {
7070
const themeCtx = useTheme()
7171
const [scrollRef, setScrollRef] = createSignal<ScrollBoxRenderable | undefined>()
7272
const [userScrolledAway, setUserScrolledAway] = createSignal(false)
73+
// Scroll lock prevents ALL scroll handling during content prepend operations
74+
const [scrollLock, setScrollLock] = createSignal(false)
7375

7476
// Reset userScrolledAway when agent changes
7577
// Use on() to explicitly track only the agent name, not other props
@@ -90,7 +92,17 @@ export function OutputWindow(props: OutputWindowProps) {
9092
debug('[OutputWindow] Scroll effect running, ref exists: %s', !!ref)
9193
if (!ref) return
9294

95+
// PRELOAD_THRESHOLD: Load earlier for smoother experience
96+
// When user is 15 lines from top, start loading
97+
const PRELOAD_THRESHOLD = 15
98+
9399
const handleScrollChange = () => {
100+
// Absolute lock during content prepend operations - prevents ALL interference
101+
if (scrollLock()) {
102+
debug('[OutputWindow] Scroll event ignored - scroll lock active')
103+
return
104+
}
105+
94106
const scrollTop = ref.scrollTop
95107
const scrollHeight = ref.scrollHeight
96108
const viewportHeight = ref.height
@@ -108,13 +120,40 @@ export function OutputWindow(props: OutputWindowProps) {
108120
setUserScrolledAway(false)
109121
}
110122

111-
// Trigger load when near the top (within 3 lines) - skip if already loading
112-
if (scrollTop <= 3 && props.hasMoreAbove && props.onLoadMore && !props.isLoadingEarlier) {
113-
debug('[OutputWindow] Loading earlier lines...')
123+
// Preload when near the top
124+
if (scrollTop <= PRELOAD_THRESHOLD && props.hasMoreAbove && props.onLoadMore && !props.isLoadingEarlier) {
125+
debug('[OutputWindow] Preloading earlier lines (scrollTop=%d, threshold=%d)...', scrollTop, PRELOAD_THRESHOLD)
126+
127+
// 1. Lock and detach listener to prevent any scroll events during operation
128+
setScrollLock(true)
129+
ref.verticalScrollBar?.off("change", handleScrollChange)
130+
131+
// 2. Capture current scroll position BEFORE loading
132+
const originalScrollTop = scrollTop
133+
134+
// 3. Load content (this updates state and triggers re-render)
114135
const linesLoaded = props.onLoadMore()
115136
debug('[OutputWindow] Lines loaded: %d', linesLoaded)
137+
116138
if (linesLoaded > 0) {
117-
ref.scrollTop = linesLoaded // Maintain view position
139+
// 4. SYNCHRONOUS scroll adjustment - no async delay
140+
// Set scroll position immediately in the same execution frame
141+
// This prevents the flash by adjusting before the next paint
142+
const newScrollTop = originalScrollTop + linesLoaded
143+
ref.scrollTop = newScrollTop
144+
debug('[OutputWindow] Scroll restored (sync): original=%d, added=%d, new=%d',
145+
originalScrollTop, linesLoaded, ref.scrollTop)
146+
147+
// 5. Use setImmediate only for re-attaching listener (after scroll is stable)
148+
setImmediate(() => {
149+
ref.verticalScrollBar?.on("change", handleScrollChange)
150+
setScrollLock(false)
151+
debug('[OutputWindow] Scroll lock released, listener re-attached')
152+
})
153+
} else {
154+
// No lines loaded, re-attach and unlock immediately
155+
ref.verticalScrollBar?.on("change", handleScrollChange)
156+
setScrollLock(false)
118157
}
119158
}
120159
}
@@ -126,7 +165,8 @@ export function OutputWindow(props: OutputWindowProps) {
126165
)
127166

128167
// Compute whether stickyScroll should be active
129-
const shouldStickyScroll = () => !userScrolledAway()
168+
// Disable during scroll lock to prevent interference with scroll position restoration
169+
const shouldStickyScroll = () => !userScrolledAway() && !scrollLock()
130170

131171
// Notify parent when user scrolls away (to pause trimming in log stream)
132172
createEffect(

src/cli/tui/routes/workflow/hooks/useLogStream.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,13 @@ function getFileSize(path: string): number {
209209

210210
/**
211211
* Calculate window size based on visible lines
212+
* Uses 3x multiplier to ensure smooth scrolling with large buffer
213+
* This means we keep 3x the visible lines in memory, allowing seamless
214+
* backward pagination without visible loading
212215
*/
213216
function calculateWindowSize(visibleLineCount: number): number {
214217
const visible = visibleLineCount || 30
215-
return Math.ceil(visible * 1.2) // 20% buffer
218+
return Math.ceil(visible * 3) // 3x buffer for smooth pagination
216219
}
217220

218221
/**
@@ -380,6 +383,9 @@ export function useLogStream(
380383
* Load earlier lines when user scrolls to top of windowed view
381384
* Returns the number of lines loaded (for scroll position adjustment)
382385
* Includes debouncing (won't load if already loading) and error handling
386+
*
387+
* Loads 2x the window size to ensure user has plenty of content above
388+
* This creates a large buffer that makes pagination invisible to the user
383389
*/
384390
function loadEarlierLines(): number {
385391
// Debounce: don't load if already loading
@@ -399,6 +405,7 @@ export function useLogStream(
399405

400406
try {
401407
const visibleLines = opts.visibleLineCount?.() || 30
408+
// Load 1x window size (which is already 3x visible) - balanced for smooth scrolling
402409
const linesToLoad = calculateWindowSize(visibleLines)
403410

404411
// Read full file and filter

0 commit comments

Comments
 (0)