fix(desktop): refresh session model metadata on switch (#43977)

Co-authored-by: Omar Baradei <omar@kostudios.io>
This commit is contained in:
Omar Baradei 2026-06-11 07:05:32 -07:00 committed by GitHub
parent d0e017bac8
commit e372803554
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 236 additions and 38 deletions

View file

@ -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)

View file

@ -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<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
> | 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<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
> = {}
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 => ({

View file

@ -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(
<Harness activeSessionId="fg-runtime" onReady={c => (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(<Harness activeSessionId="bg-runtime" onReady={c => (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(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
act(() => {
cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored')
})
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (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)
})
})

View file

@ -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

View file

@ -129,6 +129,7 @@ export interface ClientSessionState {
serviceTier: string
fast: boolean
yolo: boolean
personality: string
busy: boolean
awaitingResponse: boolean
streamId: string | null

View file

@ -46,6 +46,7 @@ export function createClientSessionState(
serviceTier: '',
fast: false,
yolo: false,
personality: '',
busy: false,
awaitingResponse: false,
streamId: null,