fix(ui-tui): redraw after session resume
This commit is contained in:
121
ui-tui/src/app/sessionResumeView.test.ts
Normal file
121
ui-tui/src/app/sessionResumeView.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
41
ui-tui/src/app/sessionResumeView.ts
Normal file
41
ui-tui/src/app/sessionResumeView.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user