From 1e5cdd339c63dc99c923b572deafc394106fbafa Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Fri, 10 Jul 2026 09:37:38 -0700 Subject: [PATCH] fix(ui-tui): redraw after session resume --- ui-tui/src/app/sessionResumeView.test.ts | 121 +++++++++++++++++++++++ ui-tui/src/app/sessionResumeView.ts | 41 ++++++++ ui-tui/src/app/useSessionLifecycle.ts | 32 +----- 3 files changed, 166 insertions(+), 28 deletions(-) create mode 100644 ui-tui/src/app/sessionResumeView.test.ts create mode 100644 ui-tui/src/app/sessionResumeView.ts diff --git a/ui-tui/src/app/sessionResumeView.test.ts b/ui-tui/src/app/sessionResumeView.test.ts new file mode 100644 index 0000000000000..f2674291b6f63 --- /dev/null +++ b/ui-tui/src/app/sessionResumeView.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { evictInkCachesMock, forceRedrawMock } = vi.hoisted(() => ({ + evictInkCachesMock: vi.fn(), + forceRedrawMock: vi.fn() +})) + +vi.mock('@hermes/ink', () => ({ + evictInkCaches: evictInkCachesMock, + forceRedraw: forceRedrawMock +})) + +import { refreshSessionView, scheduleResumeScrollToBottom } from './sessionResumeView.js' + +describe('refreshSessionView', () => { + afterEach(() => { + evictInkCachesMock.mockReset() + forceRedrawMock.mockReset() + }) + + it('evicts Ink caches and forces a full repaint', () => { + const stdout = {} as NodeJS.WriteStream + + refreshSessionView(stdout) + + expect(evictInkCachesMock).toHaveBeenCalledWith('all') + expect(forceRedrawMock).toHaveBeenCalledWith(stdout) + }) +}) + +describe('scheduleResumeScrollToBottom', () => { + afterEach(() => { + vi.useRealTimers() + evictInkCachesMock.mockReset() + forceRedrawMock.mockReset() + }) + + it('re-snaps while sticky and stops when the user scrolls away', () => { + vi.useFakeTimers() + let sticky = true + let lastManualScrollAt = 0 + const scrollToBottom = vi.fn() + + const cancel = scheduleResumeScrollToBottom( + { + current: { + getLastManualScrollAt: () => lastManualScrollAt, + isSticky: () => sticky, + scrollToBottom + } + } as any, + [0, 80, 240] + ) + + vi.advanceTimersByTime(0) + expect(scrollToBottom).toHaveBeenCalledTimes(1) + expect(evictInkCachesMock).toHaveBeenCalledWith('all') + expect(forceRedrawMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(80) + expect(scrollToBottom).toHaveBeenCalledTimes(2) + expect(forceRedrawMock).toHaveBeenCalledTimes(1) + + sticky = false + lastManualScrollAt = Date.now() + 1 + vi.advanceTimersByTime(160) + expect(scrollToBottom).toHaveBeenCalledTimes(2) + + cancel() + }) + + it('cancels pending resume snaps', () => { + vi.useFakeTimers() + const scrollToBottom = vi.fn() + + const cancel = scheduleResumeScrollToBottom( + { + current: { + getLastManualScrollAt: () => 0, + isSticky: () => true, + scrollToBottom + } + } as any, + [20] + ) + + cancel() + vi.advanceTimersByTime(20) + + expect(scrollToBottom).not.toHaveBeenCalled() + expect(forceRedrawMock).not.toHaveBeenCalled() + }) + + it('keeps the immediate resume snap even before sticky state settles', () => { + vi.useFakeTimers() + let sticky = false + const scrollToBottom = vi.fn() + + const cancel = scheduleResumeScrollToBottom( + { + current: { + getLastManualScrollAt: () => 0, + isSticky: () => sticky, + scrollToBottom + } + } as any, + [0, 80] + ) + + vi.advanceTimersByTime(0) + expect(scrollToBottom).toHaveBeenCalledTimes(1) + expect(forceRedrawMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(80) + expect(scrollToBottom).toHaveBeenCalledTimes(1) + expect(forceRedrawMock).toHaveBeenCalledTimes(1) + + sticky = true + cancel() + }) +}) diff --git a/ui-tui/src/app/sessionResumeView.ts b/ui-tui/src/app/sessionResumeView.ts new file mode 100644 index 0000000000000..131a44b5e907d --- /dev/null +++ b/ui-tui/src/app/sessionResumeView.ts @@ -0,0 +1,41 @@ +import type { ScrollBoxHandle } from '@hermes/ink' +import { evictInkCaches, forceRedraw } from '@hermes/ink' +import type { RefObject } from 'react' + +export const refreshSessionView = (stdout: NodeJS.WriteStream = process.stdout) => { + evictInkCaches('all') + forceRedraw(stdout) +} + +export const scheduleResumeScrollToBottom = ( + scrollRef: RefObject, + delays: readonly number[] = [0, 80, 240] +) => { + const startedAt = Date.now() + + const timers = delays.map((delay, index) => + setTimeout(() => { + const scroll = scrollRef.current + + if (!scroll) { + return + } + + const manuallyScrolledAfterResume = scroll.getLastManualScrollAt() > startedAt + + if (!manuallyScrolledAfterResume && (index === 0 || scroll.isSticky())) { + scroll.scrollToBottom() + + if (index === 0) { + refreshSessionView() + } + } + }, delay) + ) + + return () => { + for (const timer of timers) { + clearTimeout(timer) + } + } +} diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 9eefc8ff9b228..fb536e1f05375 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -22,10 +22,13 @@ import type { Msg, PanelSection, SessionInfo, Usage } from '../types.js' import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' +import { scheduleResumeScrollToBottom } from './sessionResumeView.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' +export { refreshSessionView, scheduleResumeScrollToBottom } from './sessionResumeView.js' + const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO) const statusFromLiveSession = (status?: string, running = false) => { @@ -68,34 +71,6 @@ export const hydrateLiveSessionInflight = (inflight?: null | SessionInflightTurn turnController.hydrateStreamingText(assistant) } -export const scheduleResumeScrollToBottom = ( - scrollRef: RefObject, - delays: readonly number[] = [0, 80, 240] -) => { - const startedAt = Date.now() - const timers = delays.map((delay, index) => - setTimeout(() => { - const scroll = scrollRef.current - - if (!scroll) { - return - } - - const manuallyScrolledAfterResume = scroll.getLastManualScrollAt() > startedAt - - if (!manuallyScrolledAfterResume && (index === 0 || scroll.isSticky())) { - scroll.scrollToBottom() - } - }, delay) - ) - - return () => { - for (const timer of timers) { - clearTimeout(timer) - } - } -} - const trimTail = (items: Msg[]) => { const q = [...items] @@ -148,6 +123,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { targetSid ? rpc('session.close', { session_id: targetSid }) : Promise.resolve(null), [rpc] ) + const cancelResumeScrollRef = useRef void)>(null) const resetSession = useCallback(() => {