fix(tui): redraw dashboard after new session (#65239)

This commit is contained in:
Gille 2026-07-15 19:23:08 -06:00 committed by GitHub
parent 3f2a389c7e
commit a16ac37dd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 74 additions and 4 deletions

View file

@ -367,6 +367,25 @@ describe('createSlashHandler', () => {
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('routes the /reset catalog alias through the local fresh-session lifecycle', () => {
const ctx = buildCtx({
local: {
catalog: {
canon: {
'/new': '/new',
'/reset': '/new'
}
}
}
})
createSlashHandler(ctx)('/reset')
getOverlayState().confirm?.onConfirm()
expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', undefined)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('keeps visible scrollback when branching a TUI session', async () => {
patchUiState({ sid: 'sid-parent' })
const rpc = vi.fn(() => Promise.resolve({ session_id: 'sid-branch', title: 'branch title' }))

View file

@ -11,9 +11,24 @@ import {
hydrateLiveSessionInflight,
liveSessionInflightMessages,
scheduleResumeScrollToBottom,
signalFreshSessionBoundary,
writeActiveSessionFile
} from '../app/useSessionLifecycle.js'
describe('fresh session boundary', () => {
it('signals only when a live session is replaced by a different session', () => {
const onFreshSessionStarted = vi.fn()
expect(signalFreshSessionBoundary('old-session', 'new-session', onFreshSessionStarted)).toBe(true)
expect(signalFreshSessionBoundary(null, 'first-session', onFreshSessionStarted)).toBe(false)
expect(signalFreshSessionBoundary('same-session', 'same-session', onFreshSessionStarted)).toBe(false)
expect(signalFreshSessionBoundary('old-session', null, onFreshSessionStarted)).toBe(false)
expect(signalFreshSessionBoundary('old-session', 'new-session')).toBe(false)
expect(onFreshSessionStarted).toHaveBeenCalledOnce()
expect(onFreshSessionStarted).toHaveBeenCalledWith('new-session')
})
})
describe('writeActiveSessionFile', () => {
let dir = ''

View file

@ -1,8 +1,16 @@
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
import {
forceRedraw,
type ScrollBoxHandle,
useApp,
useHasSelection,
useSelection,
useStdout,
useTerminalTitle
} from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { STARTUP_RESUME_ID } from '../config/env.js'
import { DASHBOARD_TUI_MODE, STARTUP_RESUME_ID } from '../config/env.js'
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
import { RESIZE_COALESCE_MS } from '../config/timing.js'
import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js'
@ -184,6 +192,7 @@ export function useMainApp(gw: GatewayClient) {
const [voiceProcessing, setVoiceProcessing] = useState(false)
const [voiceRecordKey, setVoiceRecordKey] = useState<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [dashboardFreshSessionId, setDashboardFreshSessionId] = useState<null | string>(null)
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
const [lastTurnEndedAt, setLastTurnEndedAt] = useState<null | number>(null)
// Bumped by the gateway `reaction` event (core-detected affection).
@ -496,6 +505,7 @@ export function useMainApp(gw: GatewayClient) {
colsRef,
composerActions,
gw,
onFreshSessionStarted: DASHBOARD_TUI_MODE ? setDashboardFreshSessionId : undefined,
panel,
rpc,
scrollRef,
@ -508,6 +518,12 @@ export function useMainApp(gw: GatewayClient) {
sys
})
useEffect(() => {
if (dashboardFreshSessionId) {
forceRedraw(stdout ?? process.stdout)
}
}, [dashboardFreshSessionId, stdout])
useEffect(() => {
if (ui.busy) {
setTurnStartedAt(prev => prev ?? Date.now())

View file

@ -68,6 +68,20 @@ export const hydrateLiveSessionInflight = (inflight?: null | SessionInflightTurn
turnController.hydrateStreamingText(assistant)
}
export const signalFreshSessionBoundary = (
previousSid: null | string,
nextSid: null | string,
onFreshSessionStarted?: (sessionId: string) => void
) => {
if (!previousSid || !nextSid || previousSid === nextSid || !onFreshSessionStarted) {
return false
}
onFreshSessionStarted(nextSid)
return true
}
export const scheduleResumeScrollToBottom = (
scrollRef: RefObject<null | ScrollBoxHandle>,
delays: readonly number[] = [0, 80, 240]
@ -115,6 +129,7 @@ export interface UseSessionLifecycleOptions {
colsRef: { current: number }
composerActions: ComposerActions
gw: GatewayClient
onFreshSessionStarted?: (sessionId: string) => void
panel: (title: string, sections: PanelSection[]) => void
rpc: GatewayRpc
scrollRef: RefObject<null | ScrollBoxHandle>
@ -132,6 +147,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
colsRef,
composerActions,
gw,
onFreshSessionStarted,
panel,
rpc,
scrollRef,
@ -204,8 +220,10 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
return null
}
const previousSid = getUiState().sid
if (!keepCurrent) {
await closeSession(getUiState().sid)
await closeSession(previousSid)
}
const r = await rpc<SessionCreateResponse>('session.create', { cols: colsRef.current })
@ -270,9 +288,11 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
})
}
signalFreshSessionBoundary(previousSid, r.session_id, onFreshSessionStarted)
return r.session_id
},
[closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
[closeSession, colsRef, onFreshSessionStarted, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
)
const newSession = useCallback(