From e372803554be5027be3da23090c4ddf36e255a67 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Thu, 11 Jun 2026 07:05:32 -0700 Subject: [PATCH] fix(desktop): refresh session model metadata on switch (#43977) Co-authored-by: Omar Baradei --- .../app/session/hooks/use-message-stream.ts | 91 +++++++++++++---- .../app/session/hooks/use-session-actions.ts | 63 +++++++++--- .../hooks/use-session-state-cache.test.tsx | 99 ++++++++++++++++++- .../session/hooks/use-session-state-cache.ts | 19 ++-- apps/desktop/src/app/types.ts | 1 + apps/desktop/src/lib/chat-runtime.ts | 1 + 6 files changed, 236 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 75ff43b5ee8..86244c84386 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -64,6 +64,67 @@ interface QueuedStreamDeltas { reasoning: string } +type SessionRuntimeStatePatch = Partial< + Pick< + ClientSessionState, + | 'branch' + | 'cwd' + | 'fast' + | 'model' + | 'personality' + | 'provider' + | 'reasoningEffort' + | 'serviceTier' + | 'yolo' + > +> + +function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch { + const patch: SessionRuntimeStatePatch = {} + + if (typeof payload?.model === 'string') { + patch.model = payload.model || '' + } + + if (typeof payload?.provider === 'string') { + patch.provider = payload.provider || '' + } + + if (typeof payload?.cwd === 'string') { + patch.cwd = payload.cwd + } + + if (typeof payload?.branch === 'string') { + patch.branch = payload.branch + } + + if (typeof payload?.personality === 'string') { + patch.personality = normalizePersonalityValue(payload.personality) + } + + if (typeof payload?.reasoning_effort === 'string') { + patch.reasoningEffort = payload.reasoning_effort + } + + if (typeof payload?.service_tier === 'string') { + patch.serviceTier = payload.service_tier + } + + if (typeof payload?.fast === 'boolean') { + patch.fast = payload.fast + } + + if (typeof payload?.yolo === 'boolean') { + patch.yolo = payload.yolo + } + + return patch +} + +function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean { + return Object.keys(patch).length > 0 +} + // Minimum gap between two assistant-text flushes during a stream. Was 16ms // (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every // token got its own React commit + Streamdown markdown re-parse, scaling @@ -628,36 +689,27 @@ export function useMessageStream({ // Apply session-scoped fields when the event targets the active // session, OR when it's a global broadcast and we have no session. const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current + const statePatch = sessionInfoStatePatch(payload) + const hasStatePatch = hasSessionInfoStatePatch(statePatch) const modelChanged = typeof payload?.model === 'string' const providerChanged = typeof payload?.provider === 'string' const runningChanged = typeof payload?.running === 'boolean' if (apply) { - const runtimeInfo: Partial< - Pick< - ClientSessionState, - 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo' - > - > = {} - if (modelChanged) { setCurrentModel(payload!.model || '') - runtimeInfo.model = payload!.model || '' } if (providerChanged) { setCurrentProvider(payload!.provider || '') - runtimeInfo.provider = payload!.provider || '' } if (typeof payload?.cwd === 'string') { setCurrentCwd(payload.cwd) - runtimeInfo.cwd = payload.cwd } if (typeof payload?.branch === 'string') { setCurrentBranch(payload.branch) - runtimeInfo.branch = payload.branch } if (typeof payload?.personality === 'string') { @@ -666,28 +718,31 @@ export function useMessageStream({ if (typeof payload?.reasoning_effort === 'string') { setCurrentReasoningEffort(payload.reasoning_effort) - runtimeInfo.reasoningEffort = payload.reasoning_effort } if (typeof payload?.service_tier === 'string') { setCurrentServiceTier(payload.service_tier) - runtimeInfo.serviceTier = payload.service_tier } if (typeof payload?.fast === 'boolean') { setCurrentFastMode(payload.fast) - runtimeInfo.fast = payload.fast } if (typeof payload?.yolo === 'boolean') { setYoloActive(payload.yolo) - runtimeInfo.yolo = payload.yolo } + } - if (sessionId && Object.keys(runtimeInfo).length > 0) { - updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo })) - } + if (sessionId && hasStatePatch) { + updateSessionState(sessionId, state => ({ + ...state, + ...statePatch, + branch: statePatch.branch ?? state.branch, + cwd: statePatch.cwd ?? state.cwd + })) + } + if (apply) { if (runningChanged && sessionId) { updateSessionState(sessionId, state => { const busy = Boolean(payload!.running) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 313a5357004..9980c90809d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -43,7 +43,7 @@ import { workspaceCwdForNewSession } from '@/store/session' import { reportBackendContract } from '@/store/updates' -import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes' +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' @@ -209,16 +209,27 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) { setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) } -function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial< - Pick -> | null { +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: Partial< - Pick - > = {} + const sessionState: SessionRuntimeStatePatch = {} reportBackendContract(info.desktop_contract) @@ -226,12 +237,12 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part requestDesktopOnboarding(info.credential_warning) } - if (info.model) { + if (typeof info.model === 'string') { setCurrentModel(info.model) sessionState.model = info.model } - if (info.provider) { + if (typeof info.provider === 'string') { setCurrentProvider(info.provider) sessionState.provider = info.provider } @@ -247,7 +258,9 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part } if (typeof info.personality === 'string') { - setCurrentPersonality(normalizePersonalityValue(info.personality)) + const personality = normalizePersonalityValue(info.personality) + setCurrentPersonality(personality) + sessionState.personality = personality } if (typeof info.reasoning_effort === 'string') { @@ -277,6 +290,16 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part return sessionState } +function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) { + setCurrentModel(stored?.model || '') + setCurrentProvider('') + setCurrentReasoningEffort('') + setCurrentServiceTier('') + setCurrentFastMode(false) + setYoloActive(false) + setCurrentPersonality('') +} + export function useSessionActions({ activeSessionId, activeSessionIdRef, @@ -465,15 +488,28 @@ export function useSessionActions({ const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId) if (cachedRuntimeId && cachedState) { + const stored = $sessions.get().find(session => session.id === storedSessionId) + const cachedViewState = + !cachedState.model && stored?.model != null + ? { + ...cachedState, + model: stored.model || '' + } + : cachedState + + if (cachedViewState !== cachedState) { + sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState) + } + setFreshDraftReady(false) clearNotifications() setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId setActiveSessionId(cachedRuntimeId) activeSessionIdRef.current = cachedRuntimeId - syncSessionStateToView(cachedRuntimeId, cachedState) - setCurrentCwd(cachedState.cwd) - setCurrentBranch(cachedState.branch) + syncSessionStateToView(cachedRuntimeId, cachedViewState) + setCurrentCwd(cachedViewState.cwd) + setCurrentBranch(cachedViewState.branch) setSessionStartedAt(Date.now()) try { @@ -514,6 +550,7 @@ export function useSessionActions({ selectedStoredSessionIdRef.current = storedSessionId setSessionStartedAt(Date.now()) const stored = $sessions.get().find(session => session.id === storedSessionId) + applyStoredSessionPreviewRuntimeInfo(stored) if (stored) { setCurrentUsage(current => ({ diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx index e865205d828..e2a97358273 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx @@ -2,7 +2,20 @@ import { act, cleanup, render } from '@testing-library/react' import type { MutableRefObject } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { $turnStartedAt, setTurnStartedAt } from '@/store/session' +import { + $currentFastMode, + $currentModel, + $currentProvider, + $currentReasoningEffort, + $currentServiceTier, + $turnStartedAt, + setCurrentFastMode, + setCurrentModel, + setCurrentProvider, + setCurrentReasoningEffort, + setCurrentServiceTier, + setTurnStartedAt +} from '@/store/session' import { useSessionStateCache } from './use-session-state-cache' @@ -46,12 +59,22 @@ describe('useSessionStateCache — per-session turn timer', () => { return null as unknown as number }) setTurnStartedAt(null) + setCurrentModel('') + setCurrentProvider('') + setCurrentReasoningEffort('') + setCurrentServiceTier('') + setCurrentFastMode(false) }) afterEach(() => { cleanup() vi.restoreAllMocks() setTurnStartedAt(null) + setCurrentModel('') + setCurrentProvider('') + setCurrentReasoningEffort('') + setCurrentServiceTier('') + setCurrentFastMode(false) }) it("keeps a background session's running turn clock and never mirrors it to the view", () => { @@ -115,4 +138,78 @@ describe('useSessionStateCache — per-session turn timer', () => { }) expect($turnStartedAt.get()).toBeNull() }) + + it('mirrors the focused session model metadata when switching from a cached session', () => { + let cache!: Cache + const { rerender } = render( + (cache = c)} selectedStoredSessionId="fg-stored" /> + ) + + act(() => { + cache.updateSessionState( + 'bg-runtime', + state => ({ + ...state, + fast: true, + model: 'anthropic/claude-opus-4.8', + provider: 'anthropic', + reasoningEffort: 'high', + serviceTier: 'priority' + }), + 'bg-stored' + ) + }) + + // Background metadata is cached but must not bleed into the visible statusbar. + expect($currentModel.get()).toBe('') + expect($currentReasoningEffort.get()).toBe('') + expect($currentFastMode.get()).toBe(false) + + rerender( (cache = c)} selectedStoredSessionId="bg-stored" />) + + const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime') + expect(bgState).toBeTruthy() + + act(() => { + cache.syncSessionStateToView('bg-runtime', bgState!) + }) + + expect($currentModel.get()).toBe('anthropic/claude-opus-4.8') + expect($currentProvider.get()).toBe('anthropic') + expect($currentReasoningEffort.get()).toBe('high') + expect($currentServiceTier.get()).toBe('priority') + expect($currentFastMode.get()).toBe(true) + }) + + it('clears stale model metadata when the newly focused session has no cached value', () => { + setCurrentModel('previous-model') + setCurrentProvider('previous-provider') + setCurrentReasoningEffort('high') + setCurrentServiceTier('priority') + setCurrentFastMode(true) + + let cache!: Cache + const { rerender } = render( + (cache = c)} selectedStoredSessionId="fg-stored" /> + ) + + act(() => { + cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored') + }) + + rerender( (cache = c)} selectedStoredSessionId="bg-stored" />) + + const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime') + expect(bgState).toBeTruthy() + + act(() => { + cache.syncSessionStateToView('bg-runtime', bgState!) + }) + + expect($currentModel.get()).toBe('') + expect($currentProvider.get()).toBe('') + expect($currentReasoningEffort.get()).toBe('') + expect($currentServiceTier.get()).toBe('') + expect($currentFastMode.get()).toBe(false) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 72930561bae..a08eb1f16c9 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -11,6 +11,7 @@ import { noteSessionActivity, setCurrentFastMode, setCurrentModel, + setCurrentPersonality, setCurrentProvider, setCurrentReasoningEffort, setCurrentServiceTier, @@ -53,6 +54,16 @@ interface SessionStateCacheOptions { setMessages: (messages: ChatMessage[]) => void } +function syncRuntimeMetadataToView(state: ClientSessionState) { + setCurrentModel(state.model ?? '') + setCurrentProvider(state.provider ?? '') + setCurrentReasoningEffort(state.reasoningEffort ?? '') + setCurrentServiceTier(state.serviceTier ?? '') + setCurrentFastMode(state.fast ?? false) + setYoloActive(state.yolo ?? false) + setCurrentPersonality(state.personality ?? '') +} + export function useSessionStateCache({ activeSessionId, busyRef, @@ -137,12 +148,7 @@ export function useSessionStateCache({ setMessages(nextMessages) } - setCurrentModel(pending.state.model) - setCurrentProvider(pending.state.provider) - setCurrentReasoningEffort(pending.state.reasoningEffort) - setCurrentServiceTier(pending.state.serviceTier) - setCurrentFastMode(pending.state.fast) - setYoloActive(pending.state.yolo) + syncRuntimeMetadataToView(pending.state) setBusy(pending.state.busy) setMutableRef(busyRef, pending.state.busy) setAwaitingResponse(pending.state.awaitingResponse) @@ -167,6 +173,7 @@ export function useSessionStateCache({ return } + syncRuntimeMetadataToView(state) pendingViewStateRef.current = { sessionId, state } // Terminal / attention transitions (turn finished, error, or the agent is diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 01694dc8220..5082b70406d 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -129,6 +129,7 @@ export interface ClientSessionState { serviceTier: string fast: boolean yolo: boolean + personality: string busy: boolean awaitingResponse: boolean streamId: string | null diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 68beb83a043..ac5273a2236 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -46,6 +46,7 @@ export function createClientSessionState( serviceTier: '', fast: false, yolo: false, + personality: '', busy: false, awaitingResponse: false, streamId: null,