From c8ff70fe03f5c0fb5726392bed9586544b1d8b15 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Apr 2026 13:16:18 -0500 Subject: [PATCH] perf(ui-tui): freeze offscreen live tail during scroll When the viewport is away from the bottom, keep the last visible progress snapshot instead of rebuilding the streaming/thinking subtree on every turn-store update. This cuts scroll-time churn while preserving live updates near the tail and on turn completion. --- ui-tui/src/app/useMainApp.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 39c4b534c..fdfdd8d54 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -22,7 +22,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' -import { type GatewayRpc, type TranscriptRow } from './interfaces.js' +import { type AppLayoutProgressProps, type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { $turnState, patchTurnState } from './turnStore.js' @@ -658,11 +658,36 @@ export function useMainApp(gw: GatewayClient) { [cols, composerActions, composerState, empty, pagerPageSize, submit] ) - const appProgress = useMemo( + const liveTailVisible = (() => { + const s = scrollRef.current + + if (!s) { + return true + } + + const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) + const vp = Math.max(0, s.getViewportHeight()) + const total = Math.max(vp, s.getScrollHeight()) + + return top + vp >= total - 3 + })() + + const liveProgress = useMemo( () => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), [turn, showProgressArea] ) + const frozenProgressRef = useRef(liveProgress) + + // When the live tail is offscreen, freeze its snapshot so scroll work doesn't + // keep rebuilding the streaming/thinking subtree the user can't see. Thaw as + // soon as the viewport comes back near the bottom or the turn finishes. + if (liveTailVisible || !ui.busy) { + frozenProgressRef.current = liveProgress + } + + const appProgress = liveTailVisible || !ui.busy ? liveProgress : frozenProgressRef.current + const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd() const gitBranch = useGitBranch(cwd)