Settle TUI resume scroll after hydration

This commit is contained in:
Shannon Sands 2026-06-26 16:05:15 +10:00 committed by Teknium
parent 619dc4a561
commit 6a319f570f
2 changed files with 127 additions and 4 deletions

View file

@ -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()
})
})

View file

@ -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<null | ScrollBoxHandle>,
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<SessionCloseResponse>('session.close', { session_id: targetSid }) : Promise.resolve(null),
[rpc]
)
const cancelResumeScrollRef = useRef<null | (() => 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}`)