mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-26 11:12:03 +00:00
Settle TUI resume scroll after hydration
This commit is contained in:
parent
619dc4a561
commit
6a319f570f
2 changed files with 127 additions and 4 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue