From 42721dbe1c686c88d7eb52eef6d01f53eab13836 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 11:04:29 -0500 Subject: [PATCH] fix(tui): big-session /resume now renders without first keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useVirtualHistory set up its useSyncExternalStore subscription during the first render, when scrollRef.current was still null (the ScrollBox ref attaches during commit, after render). Its useCallback for subscribe had a stable scrollRef identity as its only dep, so it never re-subscribed once the ref actually attached — the hook stayed stuck with vp=0, top=0, no scroll subscription. Small sessions fit entirely in cold-start so you didn't notice; big /resume sessions got sliced to the last 40 items with a huge topSpacer and the viewport sat on empty space until some unrelated state change (e.g. a keystroke) re-rendered and finally read a real vp. - flip a hasScrollRef flag in useLayoutEffect once the ref attaches and add it to the subscribe useCallback deps so useSyncExternalStore rearms with a real subscription - on resume, scrollToBottom() after history hydrates so the ScrollBox lands at the newest messages instead of scrollTop=0 (stickyScroll doesn't auto-engage on the initial empty→full dump) --- ui-tui/src/app/useMainApp.ts | 1 + ui-tui/src/app/useSessionLifecycle.ts | 8 ++++++-- ui-tui/src/hooks/useVirtualHistory.ts | 10 +++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 159489eb32..8fa7c10619 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -272,6 +272,7 @@ export function useMainApp(gw: GatewayClient) { gw, panel, rpc, + scrollRef, setHistoryItems, setLastUserMsg, setSessionStartedAt, diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 8114916c62..e4da3e1bf8 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -1,4 +1,5 @@ -import { useCallback } from 'react' +import type { ScrollBoxHandle } from '@hermes/ink' +import { type RefObject, useCallback } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' @@ -41,6 +42,7 @@ export interface UseSessionLifecycleOptions { gw: GatewayClient panel: (title: string, sections: PanelSection[]) => void rpc: GatewayRpc + scrollRef: RefObject setHistoryItems: StateSetter setLastUserMsg: StateSetter setSessionStartedAt: StateSetter @@ -57,6 +59,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { gw, panel, rpc, + scrollRef, setHistoryItems, setLastUserMsg, setSessionStartedAt, @@ -183,6 +186,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { status: 'ready', usage: usageFrom(r.info ?? null) }) + queueMicrotask(() => scrollRef.current?.scrollToBottom()) }) .catch((e: Error) => { sys(`error: ${e.message}`) @@ -191,7 +195,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { ) }) }, - [closeSession, colsRef, gw, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] + [closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys] ) const guardBusySessionSwitch = useCallback( diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index b92c07c462..d33971fede 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -37,9 +37,17 @@ export function useVirtualHistory( const heights = useRef(new Map()) const refs = useRef(new Map void>()) const [ver, setVer] = useState(0) + const [hasScrollRef, setHasScrollRef] = useState(false) + + useLayoutEffect(() => { + setHasScrollRef(Boolean(scrollRef.current)) + }, [scrollRef]) useSyncExternalStore( - useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => () => {}), [scrollRef]), + useCallback( + (cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? (() => () => {}), + [hasScrollRef, scrollRef] + ), () => { const s = scrollRef.current