diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts similarity index 77% rename from apps/desktop/src/app/session/hooks/use-session-actions.ts rename to apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 0e3af87bdd0..32d7d6d56c6 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -2,20 +2,16 @@ import type { MutableRefObject } from 'react' import { useCallback, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' -import { deleteSession, getSession, getSessionMessages, setSessionArchived } from '@/hermes' +import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' -import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' -import { normalizePersonalityValue } from '@/lib/chat-runtime' -import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' +import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { setSessionYolo } from '@/lib/yolo-session' import { clearQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' -import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $newChatProfile, - $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' @@ -35,11 +31,6 @@ import { setBusy, setCurrentBranch, setCurrentCwd, - setCurrentFastMode, - setCurrentModel, - setCurrentPersonality, - setCurrentProvider, - setCurrentReasoningEffort, setCurrentServiceTier, setCurrentUsage, setFreshDraftReady, @@ -56,18 +47,30 @@ import { workspaceCwdForNewSession } from '@/store/session' import { broadcastSessionsChanged } from '@/store/session-sync' -import { reportBackendContract } from '@/store/updates' import { isWatchWindow } from '@/store/windows' import type { SessionCreateResponse, - SessionInfo, SessionResumeResponse, - SessionRuntimeInfo, UsageStats } from '@/types/hermes' -import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes' -import type { ClientSessionState, SidebarNavItem } from '../../types' +import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes' +import type { ClientSessionState, SidebarNavItem } from '../../../types' + +import { + applyRuntimeInfo, + applyStoredSessionPreviewRuntimeInfo, + type BranchMessage, + chatMessageArraysEquivalent, + isSessionGoneError, + patchSessionWorkspace, + reconcileResumeMessages, + resolveStoredSession, + sessionMatchesStoredId, + sessionShouldHaveTranscript, + toBranchMessages, + upsertOptimisticSession +} from './utils' interface SessionActionsOptions { activeSessionId: string | null @@ -90,325 +93,6 @@ interface SessionActionsOptions { ) => 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.error !== b.error || - 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('')) - }) -} - -interface BranchMessage { - content: string - role: ChatMessage['role'] - source: ChatMessage -} - -// The copyable spine of a branch: user/assistant turns that carry text. -const toBranchMessages = (messages: ChatMessage[]): BranchMessage[] => - messages - .map(message => ({ content: chatMessageText(message), role: message.role, source: message })) - .filter(({ content, role }) => content.trim() && (role === 'assistant' || role === 'user')) - -function upsertOptimisticSession( - created: SessionCreateResponse, - id: string, - title: string | null = null, - preview: string | null = null, - parentSessionId: string | null = null, - lastActive?: number -) { - const now = lastActive ?? Date.now() / 1000 - // Stamp the profile the session was just created on (= the live gateway's - // profile) so the scoped sidebar shows the new row immediately instead of - // filtering it out as "default" until the aggregator re-fetches. - const profileKey = normalizeProfileKey($activeGatewayProfile.get()) - - const session: SessionInfo = { - // Seed cwd so the grouped sidebar can place the new row in its repo/worktree - // lane immediately (the overlay groups by path); fall back to the workspace - // the session was just started in when the create response omits it. - cwd: created.info?.cwd ?? ($currentCwd.get().trim() || null), - ended_at: null, - id, - input_tokens: 0, - is_active: true, - is_default_profile: profileKey === 'default', - last_active: now, - message_count: created.message_count ?? created.messages?.length ?? 0, - model: created.info?.model ?? null, - output_tokens: 0, - parent_session_id: parentSessionId, - preview, - profile: profileKey, - source: 'tui', - started_at: now, - title, - tool_call_count: 0 - } - - setSessions(prev => [session, ...prev.filter(s => s.id !== id)]) -} - -function patchSessionWorkspace(sessionId: string, cwd: string | undefined) { - if (!cwd) { - return - } - - setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) -} - -function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean { - return session.id === storedSessionId || session._lineage_root_id === storedSessionId -} - -function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean { - return (session?.message_count ?? 0) > 0 -} - -function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { - const lineage = session._lineage_root_id ?? session.id - - setSessions(prev => [ - session, - ...prev.filter(existing => { - if (sessionMatchesStoredId(existing, storedSessionId)) { - return false - } - - return (existing._lineage_root_id ?? existing.id) !== lineage - }) - ]) -} - -async function resolveStoredSession(storedSessionId: string): Promise { - const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) - - if (cached) { - return cached - } - - // Direct by-id on the live backend — one row lookup, no list scan. Covers - // single-profile users and any id on the active profile (e.g. an old session - // past the sidebar's recent window). 404 just means it's not on this profile. - try { - const session = await getSession(storedSessionId) - - upsertResolvedSession(session, storedSessionId) - - return session - } catch { - // Not on the active profile — fall through to the cross-profile probe. - } - - // Multi-profile only: probe each other profile by id (still one cheap lookup - // each) rather than pulling every profile's recent sessions. The first hit - // carries its owning `profile`, which routes the resume to the right backend. - const activeKey = normalizeProfileKey($activeGatewayProfile.get()) - - const otherProfiles = $profiles - .get() - .map(profile => normalizeProfileKey(profile.name)) - .filter(key => key !== activeKey) - - for (const profile of otherProfiles) { - try { - const session = await getSession(storedSessionId, profile) - - upsertResolvedSession(session, storedSessionId) - - return session - } catch { - // Not on this profile; try the next. - } - } - - return undefined -} - -type SessionRuntimeStatePatch = Partial< - Pick< - ClientSessionState, - 'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' - > -> - -function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null { - if (!info) { - return null - } - - const sessionState: SessionRuntimeStatePatch = {} - - reportBackendContract(info.desktop_contract) - - if (info.credential_warning) { - requestDesktopOnboarding(info.credential_warning) - } - - if (typeof info.model === 'string') { - setCurrentModel(info.model) - sessionState.model = info.model - } - - if (typeof info.provider === 'string') { - setCurrentProvider(info.provider) - sessionState.provider = info.provider - } - - if (info.cwd) { - setCurrentCwd(info.cwd) - sessionState.cwd = info.cwd - } - - if (info.branch !== undefined) { - setCurrentBranch(info.branch || '') - sessionState.branch = info.branch || '' - } - - if (typeof info.personality === 'string') { - const personality = normalizePersonalityValue(info.personality) - setCurrentPersonality(personality) - sessionState.personality = personality - } - - if (typeof info.reasoning_effort === 'string') { - setCurrentReasoningEffort(info.reasoning_effort) - sessionState.reasoningEffort = info.reasoning_effort - } - - if (typeof info.service_tier === 'string') { - setCurrentServiceTier(info.service_tier) - sessionState.serviceTier = info.service_tier - } - - if (typeof info.fast === 'boolean') { - setCurrentFastMode(info.fast) - sessionState.fast = info.fast - } - - if (typeof info.yolo === 'boolean') { - setYoloActive(info.yolo) - sessionState.yolo = info.yolo - } - - if (info.usage) { - setCurrentUsage(current => ({ ...current, ...info.usage })) - } - - return sessionState -} - -function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) { - setCurrentModel(stored?.model || '') - setCurrentProvider('') - setCurrentReasoningEffort('') - setCurrentServiceTier('') - setCurrentFastMode(false) - setYoloActive(false) - setCurrentPersonality('') -} - -// A "session genuinely doesn't exist" failure (deleted, or an id from a wiped / -// rotated backend) — the REST transcript 404s with `Session not found`. Distinct -// from a transient/wedged backend (ECONNREFUSED, timeout), which must still -// retry rather than discard the id. -function isSessionGoneError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err ?? '') - - return message.includes('404') || /session not found/i.test(message) -} - export function useSessionActions({ activeSessionId, activeSessionIdRef, @@ -685,7 +369,9 @@ export function useSessionActions({ if (warmHit) { const cachedRuntimeId = warmHit.runtimeId const cachedState = warmHit.state - const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile + + const stored = + $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile const cachedViewState = !cachedState.model && stored?.model != null @@ -752,7 +438,10 @@ export function useSessionActions({ setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId setSessionStartedAt(Date.now()) - const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile + + const stored = + $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile + applyStoredSessionPreviewRuntimeInfo(stored) if (stored) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts new file mode 100644 index 00000000000..680cc754286 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' + +import type { ChatMessage } from '@/lib/chat-messages' +import type { SessionInfo } from '@/types/hermes' + +import { + chatMessageArraysEquivalent, + isSessionGoneError, + reconcileResumeMessages, + sessionMatchesStoredId, + sessionShouldHaveTranscript, + toBranchMessages +} from './utils' + +const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial = {}): ChatMessage => + ({ id, role, parts: [{ type: 'text', text }], ...extra }) as ChatMessage + +const session = (over: Partial): SessionInfo => over as SessionInfo + +describe('isSessionGoneError', () => { + it('is true for 404 / session-not-found, false otherwise', () => { + expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true) + expect(isSessionGoneError(new Error('Session not found'))).toBe(true) + expect(isSessionGoneError(new Error('ECONNREFUSED'))).toBe(false) + expect(isSessionGoneError(null)).toBe(false) + }) +}) + +describe('sessionMatchesStoredId', () => { + it('matches on live id or lineage root', () => { + expect(sessionMatchesStoredId(session({ id: 'a' }), 'a')).toBe(true) + expect(sessionMatchesStoredId(session({ id: 'live', _lineage_root_id: 'root' }), 'root')).toBe(true) + expect(sessionMatchesStoredId(session({ id: 'a' }), 'b')).toBe(false) + }) +}) + +describe('sessionShouldHaveTranscript', () => { + it('is true only when the session has messages', () => { + expect(sessionShouldHaveTranscript(session({ message_count: 3 }))).toBe(true) + expect(sessionShouldHaveTranscript(session({ message_count: 0 }))).toBe(false) + expect(sessionShouldHaveTranscript(undefined)).toBe(false) + }) +}) + +describe('toBranchMessages', () => { + it('keeps only user/assistant turns that carry text', () => { + const out = toBranchMessages([ + msg('u', 'user', 'hi'), + msg('blank', 'assistant', ' '), + msg('sys', 'system', 'ignored'), + msg('a', 'assistant', 'hello') + ]) + + expect(out.map(b => b.source.id)).toEqual(['u', 'a']) + expect(out[0]).toMatchObject({ content: 'hi', role: 'user' }) + }) +}) + +describe('chatMessageArraysEquivalent', () => { + it('compares length and per-message equivalence', () => { + const a = [msg('1', 'user', 'x'), msg('2', 'assistant', 'y')] + expect(chatMessageArraysEquivalent(a, [msg('1', 'user', 'x'), msg('2', 'assistant', 'y')])).toBe(true) + expect(chatMessageArraysEquivalent(a, [msg('1', 'user', 'x')])).toBe(false) + expect(chatMessageArraysEquivalent(a, [msg('1', 'user', 'x'), msg('2', 'assistant', 'changed')])).toBe(false) + }) +}) + +describe('reconcileResumeMessages', () => { + it('returns next untouched when there is no previous transcript', () => { + const next = [msg('1', 'user', 'hi')] + expect(reconcileResumeMessages(next, [])).toBe(next) + }) + + it('re-grafts reasoning parts onto a matching assistant turn', () => { + const next = [msg('a', 'assistant', 'answer')] + + const previous = [ + msg('a', 'assistant', 'answer', { + parts: [ + { type: 'reasoning', text: 'thinking' }, + { type: 'text', text: 'answer' } + ] + } as Partial) + ] + + const [out] = reconcileResumeMessages(next, previous) + expect(out.parts.some(p => p.type === 'reasoning')).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts new file mode 100644 index 00000000000..254a58e1298 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -0,0 +1,344 @@ +import { getSession } from '@/hermes' +import { type ChatMessage, chatMessageText } from '@/lib/chat-messages' +import { normalizePersonalityValue } from '@/lib/chat-runtime' +import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' +import { requestDesktopOnboarding } from '@/store/onboarding' +import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' +import { + $currentCwd, + $sessions, + setCurrentBranch, + setCurrentCwd, + setCurrentFastMode, + setCurrentModel, + setCurrentPersonality, + setCurrentProvider, + setCurrentReasoningEffort, + setCurrentServiceTier, + setCurrentUsage, + setSessions, + setYoloActive +} from '@/store/session' +import { reportBackendContract } from '@/store/updates' +import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes' + +import type { ClientSessionState } from '../../../types' + +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.error !== b.error || + 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])) +} + +export function chatMessageArraysEquivalent(a: ChatMessage[], b: ChatMessage[]): boolean { + return a.length === b.length && a.every((message, index) => chatMessagesEquivalent(message, b[index])) +} + +export 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('')) + }) +} + +export interface BranchMessage { + content: string + role: ChatMessage['role'] + source: ChatMessage +} + +// The copyable spine of a branch: user/assistant turns that carry text. +export const toBranchMessages = (messages: ChatMessage[]): BranchMessage[] => + messages + .map(message => ({ content: chatMessageText(message), role: message.role, source: message })) + .filter(({ content, role }) => content.trim() && (role === 'assistant' || role === 'user')) + +export function upsertOptimisticSession( + created: SessionCreateResponse, + id: string, + title: string | null = null, + preview: string | null = null, + parentSessionId: string | null = null, + lastActive?: number +) { + const now = lastActive ?? Date.now() / 1000 + // Stamp the profile the session was just created on (= the live gateway's + // profile) so the scoped sidebar shows the new row immediately instead of + // filtering it out as "default" until the aggregator re-fetches. + const profileKey = normalizeProfileKey($activeGatewayProfile.get()) + + const session: SessionInfo = { + // Seed cwd so the grouped sidebar can place the new row in its repo/worktree + // lane immediately (the overlay groups by path); fall back to the workspace + // the session was just started in when the create response omits it. + cwd: created.info?.cwd ?? ($currentCwd.get().trim() || null), + ended_at: null, + id, + input_tokens: 0, + is_active: true, + is_default_profile: profileKey === 'default', + last_active: now, + message_count: created.message_count ?? created.messages?.length ?? 0, + model: created.info?.model ?? null, + output_tokens: 0, + parent_session_id: parentSessionId, + preview, + profile: profileKey, + source: 'tui', + started_at: now, + title, + tool_call_count: 0 + } + + setSessions(prev => [session, ...prev.filter(s => s.id !== id)]) +} + +export function patchSessionWorkspace(sessionId: string, cwd: string | undefined) { + if (!cwd) { + return + } + + setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) +} + +export function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean { + return session.id === storedSessionId || session._lineage_root_id === storedSessionId +} + +export function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean { + return (session?.message_count ?? 0) > 0 +} + +function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { + const lineage = session._lineage_root_id ?? session.id + + setSessions(prev => [ + session, + ...prev.filter(existing => { + if (sessionMatchesStoredId(existing, storedSessionId)) { + return false + } + + return (existing._lineage_root_id ?? existing.id) !== lineage + }) + ]) +} + +export async function resolveStoredSession(storedSessionId: string): Promise { + const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) + + if (cached) { + return cached + } + + // Direct by-id on the live backend — one row lookup, no list scan. Covers + // single-profile users and any id on the active profile (e.g. an old session + // past the sidebar's recent window). 404 just means it's not on this profile. + try { + const session = await getSession(storedSessionId) + + upsertResolvedSession(session, storedSessionId) + + return session + } catch { + // Not on the active profile — fall through to the cross-profile probe. + } + + // Multi-profile only: probe each other profile by id (still one cheap lookup + // each) rather than pulling every profile's recent sessions. The first hit + // carries its owning `profile`, which routes the resume to the right backend. + const activeKey = normalizeProfileKey($activeGatewayProfile.get()) + + const otherProfiles = $profiles + .get() + .map(profile => normalizeProfileKey(profile.name)) + .filter(key => key !== activeKey) + + for (const profile of otherProfiles) { + try { + const session = await getSession(storedSessionId, profile) + + upsertResolvedSession(session, storedSessionId) + + return session + } catch { + // Not on this profile; try the next. + } + } + + return undefined +} + +type SessionRuntimeStatePatch = Partial< + Pick< + ClientSessionState, + 'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' + > +> + +export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null { + if (!info) { + return null + } + + const sessionState: SessionRuntimeStatePatch = {} + + reportBackendContract(info.desktop_contract) + + if (info.credential_warning) { + requestDesktopOnboarding(info.credential_warning) + } + + if (typeof info.model === 'string') { + setCurrentModel(info.model) + sessionState.model = info.model + } + + if (typeof info.provider === 'string') { + setCurrentProvider(info.provider) + sessionState.provider = info.provider + } + + if (info.cwd) { + setCurrentCwd(info.cwd) + sessionState.cwd = info.cwd + } + + if (info.branch !== undefined) { + setCurrentBranch(info.branch || '') + sessionState.branch = info.branch || '' + } + + if (typeof info.personality === 'string') { + const personality = normalizePersonalityValue(info.personality) + setCurrentPersonality(personality) + sessionState.personality = personality + } + + if (typeof info.reasoning_effort === 'string') { + setCurrentReasoningEffort(info.reasoning_effort) + sessionState.reasoningEffort = info.reasoning_effort + } + + if (typeof info.service_tier === 'string') { + setCurrentServiceTier(info.service_tier) + sessionState.serviceTier = info.service_tier + } + + if (typeof info.fast === 'boolean') { + setCurrentFastMode(info.fast) + sessionState.fast = info.fast + } + + if (typeof info.yolo === 'boolean') { + setYoloActive(info.yolo) + sessionState.yolo = info.yolo + } + + if (info.usage) { + setCurrentUsage(current => ({ ...current, ...info.usage })) + } + + return sessionState +} + +export function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) { + setCurrentModel(stored?.model || '') + setCurrentProvider('') + setCurrentReasoningEffort('') + setCurrentServiceTier('') + setCurrentFastMode(false) + setYoloActive(false) + setCurrentPersonality('') +} + +// A "session genuinely doesn't exist" failure (deleted, or an id from a wiped / +// rotated backend) — the REST transcript 404s with `Session not found`. Distinct +// from a transient/wedged backend (ECONNREFUSED, timeout), which must still +// retry rather than discard the id. +export function isSessionGoneError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err ?? '') + + return message.includes('404') || /session not found/i.test(message) +}