diff --git a/ui-tui/src/__tests__/useSessionLifecycle.test.ts b/ui-tui/src/__tests__/useSessionLifecycle.test.ts index 46db4889554..05a79cae322 100644 --- a/ui-tui/src/__tests__/useSessionLifecycle.test.ts +++ b/ui-tui/src/__tests__/useSessionLifecycle.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { turnController } from '../app/turnController.js' import { getTurnState, resetTurnState } from '../app/turnStore.js' @@ -10,6 +10,7 @@ import { patchUiState, resetUiState } from '../app/uiStore.js' import { hydrateLiveSessionInflight, liveSessionInflightMessages, + scheduleResumeScrollToBottom, writeActiveSessionFile } from '../app/useSessionLifecycle.js' @@ -61,3 +62,84 @@ describe('live session activation in-flight state', () => { expect(getTurnState().streaming).toBe('') }) }) + +describe('resume scroll settle', () => { + afterEach(() => { + vi.useRealTimers() + }) + + 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) + + vi.advanceTimersByTime(80) + expect(scrollToBottom).toHaveBeenCalledTimes(2) + + 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() + }) + + 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) + + vi.advanceTimersByTime(80) + expect(scrollToBottom).toHaveBeenCalledTimes(1) + + sticky = true + cancel() + }) +}) diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index e95dafef2be..9eefc8ff9b2 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs' import type { ScrollBoxHandle } from '@hermes/ink' import { evictInkCaches } from '@hermes/ink' -import { type RefObject, useCallback } from 'react' +import { type RefObject, useCallback, useEffect, useRef } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' @@ -68,6 +68,34 @@ 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] @@ -120,8 +148,11 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { targetSid ? rpc('session.close', { session_id: targetSid }) : Promise.resolve(null), [rpc] ) + const cancelResumeScrollRef = useRef void)>(null) const resetSession = useCallback(() => { + cancelResumeScrollRef.current?.() + cancelResumeScrollRef.current = null turnController.fullReset() setVoiceRecording(false) setVoiceProcessing(false) @@ -135,6 +166,14 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { evictInkCaches('half') }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]) + useEffect( + () => () => { + cancelResumeScrollRef.current?.() + cancelResumeScrollRef.current = null + }, + [] + ) + const resetVisibleHistory = useCallback( (info: null | SessionInfo = null) => { turnController.idle() @@ -279,7 +318,8 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { usage: usageFrom(info) }) hydrateLiveSessionInflight(r.inflight) - setTimeout(() => scrollRef.current?.scrollToBottom(), 0) + cancelResumeScrollRef.current?.() + cancelResumeScrollRef.current = scheduleResumeScrollToBottom(scrollRef) }) .catch((e: Error) => { sys(`error: ${e.message}`) @@ -332,12 +372,13 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { usage: usageFrom(info) }) hydrateLiveSessionInflight(r.inflight) + cancelResumeScrollRef.current?.() + cancelResumeScrollRef.current = scheduleResumeScrollToBottom(scrollRef) if (previousSid && previousSid !== r.session_id) { void closeSession(previousSid) } - setTimeout(() => scrollRef.current?.scrollToBottom(), 0) }) .catch((e: Error) => { sys(`error: ${e.message}`)