| import type { ScrollBoxHandle } from '@hermes/ink' |
| import { type RefObject, useCallback } from 'react' |
|
|
| import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' |
| import { introMsg, toTranscriptMessages } from '../domain/messages.js' |
| import { ZERO } from '../domain/usage.js' |
| import { type GatewayClient } from '../gatewayClient.js' |
| import type { |
| SessionCloseResponse, |
| SessionCreateResponse, |
| SessionResumeResponse, |
| SetupStatusResponse |
| } from '../gatewayTypes.js' |
| import { asRpcResult } from '../lib/rpc.js' |
| import type { Msg, PanelSection, SessionInfo, Usage } from '../types.js' |
|
|
| import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js' |
| import { patchOverlayState } from './overlayStore.js' |
| import { turnController } from './turnController.js' |
| import { patchTurnState } from './turnStore.js' |
| import { getUiState, patchUiState } from './uiStore.js' |
|
|
| const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO) |
|
|
| const trimTail = (items: Msg[]) => { |
| const q = [...items] |
|
|
| while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { |
| q.pop() |
| } |
|
|
| if (q.at(-1)?.role === 'user') { |
| q.pop() |
| } |
|
|
| return q |
| } |
|
|
| export interface UseSessionLifecycleOptions { |
| colsRef: { current: number } |
| composerActions: ComposerActions |
| gw: GatewayClient |
| panel: (title: string, sections: PanelSection[]) => void |
| rpc: GatewayRpc |
| scrollRef: RefObject<null | ScrollBoxHandle> |
| setHistoryItems: StateSetter<Msg[]> |
| setLastUserMsg: StateSetter<string> |
| setSessionStartedAt: StateSetter<number> |
| setStickyPrompt: StateSetter<string> |
| setVoiceProcessing: StateSetter<boolean> |
| setVoiceRecording: StateSetter<boolean> |
| sys: (text: string) => void |
| } |
|
|
| export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { |
| const { |
| colsRef, |
| composerActions, |
| gw, |
| panel, |
| rpc, |
| scrollRef, |
| setHistoryItems, |
| setLastUserMsg, |
| setSessionStartedAt, |
| setStickyPrompt, |
| setVoiceProcessing, |
| setVoiceRecording, |
| sys |
| } = opts |
|
|
| const closeSession = useCallback( |
| (targetSid?: null | string) => |
| targetSid ? rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : Promise.resolve(null), |
| [rpc] |
| ) |
|
|
| const resetSession = useCallback(() => { |
| turnController.fullReset() |
| setVoiceRecording(false) |
| setVoiceProcessing(false) |
| patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO }) |
| setHistoryItems([]) |
| setLastUserMsg('') |
| setStickyPrompt('') |
| composerActions.setPasteSnips([]) |
| }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]) |
|
|
| const resetVisibleHistory = useCallback( |
| (info: null | SessionInfo = null) => { |
| turnController.idle() |
| turnController.clearReasoning() |
| turnController.turnTools = [] |
| turnController.persistedToolLabels.clear() |
|
|
| setHistoryItems(info ? [introMsg(info)] : []) |
| setStickyPrompt('') |
| setLastUserMsg('') |
| composerActions.setPasteSnips([]) |
| patchTurnState({ activity: [] }) |
| patchUiState({ info, usage: usageFrom(info) }) |
| }, |
| [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt] |
| ) |
|
|
| const newSession = useCallback( |
| async (msg?: string) => { |
| const setup = await rpc<SetupStatusResponse>('setup.status', {}) |
|
|
| if (setup?.provider_configured === false) { |
| panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) |
| patchUiState({ status: 'setup required' }) |
|
|
| return |
| } |
|
|
| await closeSession(getUiState().sid) |
|
|
| const r = await rpc<SessionCreateResponse>('session.create', { cols: colsRef.current }) |
|
|
| if (!r) { |
| return patchUiState({ status: 'ready' }) |
| } |
|
|
| const info = r.info ?? null |
|
|
| resetSession() |
| setSessionStartedAt(Date.now()) |
|
|
| patchUiState({ |
| info, |
| sid: r.session_id, |
| status: info?.version ? 'ready' : 'starting agent…', |
| usage: usageFrom(info) |
| }) |
|
|
| if (info) { |
| setHistoryItems([introMsg(info)]) |
| } |
|
|
| if (info?.credential_warning) { |
| sys(`warning: ${info.credential_warning}`) |
| } |
|
|
| if (msg) { |
| sys(msg) |
| } |
| }, |
| [closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] |
| ) |
|
|
| const resumeById = useCallback( |
| (id: string) => { |
| patchOverlayState({ picker: false }) |
| patchUiState({ status: 'resuming…' }) |
|
|
| rpc<SetupStatusResponse>('setup.status', {}).then(setup => { |
| if (setup?.provider_configured === false) { |
| panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) |
| patchUiState({ status: 'setup required' }) |
|
|
| return |
| } |
|
|
| closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => |
| gw |
| .request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id }) |
| .then(raw => { |
| const r = asRpcResult<SessionResumeResponse>(raw) |
|
|
| if (!r) { |
| sys('error: invalid response: session.resume') |
|
|
| return patchUiState({ status: 'ready' }) |
| } |
|
|
| resetSession() |
| setSessionStartedAt(Date.now()) |
|
|
| const resumed = toTranscriptMessages(r.messages) |
|
|
| setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) |
| patchUiState({ |
| info: r.info ?? null, |
| sid: r.session_id, |
| status: 'ready', |
| usage: usageFrom(r.info ?? null) |
| }) |
| setTimeout(() => scrollRef.current?.scrollToBottom(), 0) |
| }) |
| .catch((e: Error) => { |
| sys(`error: ${e.message}`) |
| patchUiState({ status: 'ready' }) |
| }) |
| ) |
| }) |
| }, |
| [closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys] |
| ) |
|
|
| const guardBusySessionSwitch = useCallback( |
| (what = 'switch sessions') => { |
| if (!getUiState().busy) { |
| return false |
| } |
|
|
| sys(`interrupt the current turn before trying to ${what}`) |
|
|
| return true |
| }, |
| [sys] |
| ) |
|
|
| return { |
| closeSession, |
| guardBusySessionSwitch, |
| newSession, |
| resetSession, |
| resetVisibleHistory, |
| resumeById, |
| trimLastExchange: trimTail |
| } |
| } |
|
|