From a16ac37dd99b59b2af3eea83e9cfa6c2e50bb188 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:23:08 -0600 Subject: [PATCH] fix(tui): redraw dashboard after new session (#65239) --- .../src/__tests__/createSlashHandler.test.ts | 19 +++++++++++++++ .../src/__tests__/useSessionLifecycle.test.ts | 15 ++++++++++++ ui-tui/src/app/useMainApp.ts | 20 ++++++++++++++-- ui-tui/src/app/useSessionLifecycle.ts | 24 +++++++++++++++++-- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index e5f97c7e3562..b4dc5f7b6761 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -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' })) diff --git a/ui-tui/src/__tests__/useSessionLifecycle.test.ts b/ui-tui/src/__tests__/useSessionLifecycle.test.ts index e441286abc3f..f214aa183533 100644 --- a/ui-tui/src/__tests__/useSessionLifecycle.test.ts +++ b/ui-tui/src/__tests__/useSessionLifecycle.test.ts @@ -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 = '' diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index a7b5e2d2e4ee..1b921b814b32 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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(DEFAULT_VOICE_RECORD_KEY) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) + const [dashboardFreshSessionId, setDashboardFreshSessionId] = useState(null) const [turnStartedAt, setTurnStartedAt] = useState(null) const [lastTurnEndedAt, setLastTurnEndedAt] = useState(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()) diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 1c92935bab82..4cce57bae560 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -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, 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 @@ -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('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(