fix(ui-tui): redraw after session resume

This commit is contained in:
LeonSGP43
2026-07-10 09:37:38 -07:00
committed by Teknium
parent 96e794aa4a
commit 6356dc392e
3 changed files with 165 additions and 29 deletions

View File

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

View File

@@ -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<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()
if (index === 0) {
refreshSessionView()
}
}
}, delay)
)
return () => {
for (const timer of timers) {
clearTimeout(timer)
}
}
}

View File

@@ -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) => {
@@ -82,35 +85,6 @@ export const signalFreshSessionBoundary = (
return true
}
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]