import type { MutableRefObject } from 'react' import { useCallback, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' import { deleteSession, getSessionMessages } from '@/hermes' import { type ChatMessage, chatMessageText, toChatMessages } from '@/lib/chat-messages' import { normalizePersonalityValue } from '@/lib/chat-runtime' import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' import { clearComposerAttachments, clearComposerDraft } from '@/store/composer' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { $messages, $sessions, setActiveSessionId, setAwaitingResponse, setBusy, setCurrentBranch, setCurrentCwd, setCurrentModel, setCurrentPersonality, setCurrentProvider, setFreshDraftReady, setIntroSeed, setMessages, setSelectedStoredSessionId, setSessions } from '@/store/session' import type { SessionCreateResponse, SessionInfo, SessionResumeResponse } from '@/types/hermes' import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes' import type { ClientSessionState, SidebarNavItem } from '../../types' interface SessionActionsOptions { activeSessionId: string | null activeSessionIdRef: MutableRefObject busyRef: MutableRefObject creatingSessionRef: MutableRefObject ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState getRouteToken: () => string navigate: NavigateFunction requestGateway: (method: string, params?: Record) => Promise runtimeIdByStoredSessionIdRef: MutableRefObject> selectedStoredSessionId: string | null selectedStoredSessionIdRef: MutableRefObject sessionStateByRuntimeIdRef: MutableRefObject> syncSessionStateToView: (sessionId: string, state: ClientSessionState) => void updateSessionState: ( sessionId: string, updater: (state: ClientSessionState) => ClientSessionState, storedSessionId?: string | null ) => ClientSessionState } function withAppendedText(message: ChatMessage, suffix: string): ChatMessage { let appended = false const parts = message.parts.map(part => { if (part.type !== 'text' || appended) { return part } appended = true return { ...part, text: `${part.text}${suffix}` } }) return appended ? { ...message, parts } : message } function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): ChatMessage { if (message.parts.some(part => part.type === 'reasoning')) { return message } const reasoningParts = previous.parts.filter(part => part.type === 'reasoning') return reasoningParts.length ? { ...message, parts: [...reasoningParts, ...message.parts] } : message } function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean { if (a.id !== b.id || a.role !== b.role || a.pending !== b.pending || a.hidden !== b.hidden || a.branchGroupId !== b.branchGroupId) { return false } if (a.parts.length !== b.parts.length) { return false } return a.parts.every((part, index) => JSON.stringify(part) === JSON.stringify(b.parts[index])) } function chatMessageArraysEquivalent(a: ChatMessage[], b: ChatMessage[]): boolean { return a.length === b.length && a.every((message, index) => chatMessagesEquivalent(message, b[index])) } function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages: ChatMessage[]): ChatMessage[] { if (!previousMessages.length) { return nextMessages } const previousByRoleOrdinal = new Map() const previousRoleCounts = new Map() for (const message of previousMessages) { const ordinal = previousRoleCounts.get(message.role) ?? 0 previousRoleCounts.set(message.role, ordinal + 1) previousByRoleOrdinal.set(`${message.role}:${ordinal}`, message) } const nextRoleCounts = new Map() return nextMessages.map(message => { const ordinal = nextRoleCounts.get(message.role) ?? 0 nextRoleCounts.set(message.role, ordinal + 1) const previous = previousByRoleOrdinal.get(`${message.role}:${ordinal}`) if (!previous) { return message } const nextText = chatMessageText(message).trim() const previousText = chatMessageText(previous) const previousVisibleText = textWithoutEmbeddedImages(previousText) let preserved = message if (nextText === previousVisibleText || nextText === previousText.trim()) { preserved = preserveReasoningParts(preserved, previous) } const previousImages = embeddedImageUrls(previousText) if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) { return preserved } if (nextText !== previousVisibleText) { return preserved } return withAppendedText(preserved, previousImages.map(url => `\n${url}`).join('')) }) } function upsertOptimisticSession( created: SessionCreateResponse, id: string, title: string | null = null, preview: string | null = null ) { const now = Date.now() / 1000 const session: SessionInfo = { ended_at: null, id, input_tokens: 0, is_active: true, last_active: now, message_count: created.message_count ?? created.messages?.length ?? 0, model: created.info?.model ?? null, output_tokens: 0, preview, source: 'tui', started_at: now, title, tool_call_count: 0 } setSessions(prev => [session, ...prev.filter(s => s.id !== id)]) } function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) { if (!info) { return } if (info.model) { setCurrentModel(info.model) } if (info.provider) { setCurrentProvider(info.provider) } if (info.cwd) { setCurrentCwd(info.cwd) } if (info.branch !== undefined) { setCurrentBranch(info.branch || '') } if (typeof info.personality === 'string') { setCurrentPersonality(normalizePersonalityValue(info.personality)) } } export function useSessionActions({ activeSessionId, activeSessionIdRef, busyRef, creatingSessionRef, ensureSessionState, getRouteToken, navigate, requestGateway, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, syncSessionStateToView, updateSessionState }: SessionActionsOptions) { const resumeRequestRef = useRef(0) const startFreshSessionDraft = useCallback( (replaceRoute = false) => { busyRef.current = false setBusy(false) setAwaitingResponse(false) clearNotifications() setIntroSeed(seed => seed + 1) navigate(NEW_CHAT_ROUTE, { replace: replaceRoute }) setActiveSessionId(null) activeSessionIdRef.current = null setSelectedStoredSessionId(null) selectedStoredSessionIdRef.current = null setMessages([]) clearComposerDraft() clearComposerAttachments() setFreshDraftReady(true) }, [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef] ) const createBackendSessionForSend = useCallback(async (): Promise => { const startingActiveSessionId = activeSessionIdRef.current const startingStoredSessionId = selectedStoredSessionIdRef.current const startingRouteToken = getRouteToken() creatingSessionRef.current = true try { const created = await requestGateway('session.create', { cols: 96 }) const stored = created.stored_session_id ?? null if ( activeSessionIdRef.current !== startingActiveSessionId || selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken ) { await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined) return null } activeSessionIdRef.current = created.session_id selectedStoredSessionIdRef.current = stored ensureSessionState(created.session_id, stored) if (stored) { upsertOptimisticSession(created, stored) navigate(sessionRoute(stored), { replace: true }) } setFreshDraftReady(false) setActiveSessionId(created.session_id) setSelectedStoredSessionId(stored) applyRuntimeInfo(created.info) return created.session_id } finally { window.setTimeout(() => { creatingSessionRef.current = false }, 0) } }, [activeSessionIdRef, creatingSessionRef, ensureSessionState, getRouteToken, navigate, requestGateway, selectedStoredSessionIdRef]) const selectSidebarItem = useCallback( (item: SidebarNavItem) => { if (item.action === 'new-session') { startFreshSessionDraft() return } if (item.route) { navigate(item.route) } }, [navigate, startFreshSessionDraft] ) const openSettings = useCallback(() => { navigate(SETTINGS_ROUTE) }, [navigate]) const closeSettings = useCallback(() => { if (selectedStoredSessionId) { navigate(sessionRoute(selectedStoredSessionId)) return } navigate(NEW_CHAT_ROUTE) }, [navigate, selectedStoredSessionId]) const resumeSession = useCallback( async (storedSessionId: string, replaceRoute = false) => { const requestId = resumeRequestRef.current + 1 resumeRequestRef.current = requestId const isCurrentResume = () => resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId) if (cachedRuntimeId && cachedState) { setFreshDraftReady(false) clearNotifications() setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId setActiveSessionId(cachedRuntimeId) activeSessionIdRef.current = cachedRuntimeId syncSessionStateToView(cachedRuntimeId, cachedState) clearComposerDraft() clearComposerAttachments() return } setFreshDraftReady(false) setActiveSessionId(null) activeSessionIdRef.current = null busyRef.current = true setBusy(true) setAwaitingResponse(false) clearNotifications() setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId try { // Load the local snapshot first, then ask the gateway to resume. // Previously these raced: // 1. clear messages to [] // 2. local getSessionMessages -> 45 msgs // 3. a second resume path cleared [] again // 4. gateway resume -> 43 msgs // That is the ctrl+R flash chain. Avoid showing an empty thread // while we already have a route-scoped session id, and don't race the // local snapshot against gateway resume. let localSnapshot = $messages.get() try { const storedMessages = await getSessionMessages(storedSessionId) if (isCurrentResume()) { localSnapshot = toChatMessages(storedMessages.messages) if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) { setMessages(localSnapshot) } } } catch { // Non-fatal: gateway resume below can still hydrate the session. } const resumed = await requestGateway('session.resume', { session_id: storedSessionId, cols: 96 }) if (!isCurrentResume()) { return } const currentMessages = $messages.get() const resumedMessages = reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages) // Avoid a second visible transcript rebuild on resume/switch. // `getSessionMessages()` is the stable stored transcript snapshot and // paints first; `session.resume` can return a slightly different // runtime-shaped projection (e.g. tool/system coalescing), which was // causing a second full message-list replacement a second later. // Keep the already-painted local snapshot for the view/cache when it // exists; use gateway messages only as a fallback when no local // snapshot was available. const messagesForView = localSnapshot.length > 0 ? localSnapshot : chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages setActiveSessionId(resumed.session_id) activeSessionIdRef.current = resumed.session_id updateSessionState( resumed.session_id, state => ({ ...state, messages: messagesForView, busy: false, awaitingResponse: false }), storedSessionId ) clearComposerDraft() clearComposerAttachments() applyRuntimeInfo(resumed.info) } catch (err) { if (!isCurrentResume()) { return } const fallback = await getSessionMessages(storedSessionId) if (!isCurrentResume()) { return } setMessages(toChatMessages(fallback.messages)) notifyError(err, 'Resume failed') } finally { if (isCurrentResume()) { busyRef.current = false setBusy(false) setAwaitingResponse(false) } } }, [ activeSessionIdRef, busyRef, requestGateway, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, syncSessionStateToView, updateSessionState ] ) const branchCurrentSession = useCallback( async (messageId?: string): Promise => { const sourceSessionId = activeSessionIdRef.current if (!sourceSessionId) { notify({ kind: 'warning', title: 'Nothing to branch', message: 'Start or resume a chat before branching.' }) return false } if (busyRef.current) { notify({ kind: 'warning', title: 'Session busy', message: 'Stop the current turn before branching this chat.' }) return false } creatingSessionRef.current = true try { const currentMessages = $messages.get() const targetIndex = messageId ? currentMessages.findIndex(message => message.id === messageId) : currentMessages.findLastIndex(message => message.role === 'assistant' || message.role === 'user') const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0) const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length const branchMessages = currentMessages .slice(branchStart, branchEnd) .map(message => ({ content: chatMessageText(message), source: message, role: message.role })) .filter(message => message.content.trim() && ['assistant', 'user'].includes(message.role)) if (!branchMessages.length) { notify({ kind: 'warning', title: 'Nothing to branch', message: 'This message has no text to branch from.' }) return false } clearNotifications() const branched = await requestGateway('session.create', { cols: 96, messages: branchMessages.map(({ content, role }) => ({ content, role })), title: 'Branch' }) const routedSessionId = branched.stored_session_id ?? branched.session_id const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null setFreshDraftReady(false) upsertOptimisticSession(branched, routedSessionId, 'Branch', preview) ensureSessionState(branched.session_id, routedSessionId) setActiveSessionId(branched.session_id) activeSessionIdRef.current = branched.session_id updateSessionState( branched.session_id, state => ({ ...state, messages: branchMessages.map(({ source }) => source), busy: false, awaitingResponse: false }), routedSessionId ) setSelectedStoredSessionId(routedSessionId) selectedStoredSessionIdRef.current = routedSessionId navigate(sessionRoute(routedSessionId)) clearComposerDraft() clearComposerAttachments() applyRuntimeInfo(branched.info) return true } catch (err) { notifyError(err, 'Branch failed') return false } finally { window.setTimeout(() => { creatingSessionRef.current = false }, 0) } }, [ activeSessionIdRef, busyRef, creatingSessionRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef, updateSessionState ] ) const removeSession = useCallback( async (storedSessionId: string) => { clearNotifications() const removed = $sessions.get().find(s => s.id === storedSessionId) const wasSelected = selectedStoredSessionId === storedSessionId const closingRuntimeId = wasSelected ? activeSessionId : null const previousMessages = $messages.get() const previousPinned = $pinnedSessionIds.get() setSessions(prev => prev.filter(s => s.id !== storedSessionId)) $pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId)) // Tear down before awaiting so the route effect can't resume the // doomed session via the stale / URL. if (wasSelected) { startFreshSessionDraft(true) } try { if (closingRuntimeId) { await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined) } await deleteSession(storedSessionId) } catch (err) { if (removed) { setSessions(prev => [removed, ...prev]) } $pinnedSessionIds.set(previousPinned) if (wasSelected) { setFreshDraftReady(false) setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId setMessages(previousMessages) navigate(sessionRoute(storedSessionId), { replace: true }) if (closingRuntimeId) { setActiveSessionId(closingRuntimeId) activeSessionIdRef.current = closingRuntimeId } } notifyError(err, 'Delete failed') } }, [ activeSessionId, activeSessionIdRef, navigate, requestGateway, selectedStoredSessionId, selectedStoredSessionIdRef, startFreshSessionDraft ] ) return { branchCurrentSession, closeSettings, createBackendSessionForSend, openSettings, removeSession, resumeSession, selectSidebarItem, startFreshSessionDraft } }