diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index f35e9efed95f..66c42a941d07 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -7655,12 +7655,20 @@ function createWindow() { mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString()) } + // Start the Python backend NOW, in parallel with the renderer load — not on + // did-finish-load. The backend cold boot (spawn → port announce → /api/status) + // is the dominant startup cost, and serializing it behind Chromium's load + // added the whole renderer load time to first-usable-composer. The promise is + // shared (backendConnectionState), so the renderer's getConnection() joins + // this in-flight boot instead of duplicating it; early boot-progress events + // the renderer misses are recovered by its getBootProgress() pull on mount. + startHermes().catch(error => rememberLog(error.stack || error.message)) + mainWindow.webContents.once('did-finish-load', () => { // Zoom restore is handled by wireCommonWindowHandlers (shared with session // windows); no need to reapply it here. broadcastBootProgress() sendWindowStateChanged() - startHermes().catch(error => rememberLog(error.stack || error.message)) }) } diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 93ec2c5d7714..786cf4011ab7 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -284,10 +284,14 @@ export function useGatewayBoot({ return } + // Same shape as boot(): profile first (session scope depends on it), + // then the independent fetches concurrently. await adoptPrimaryProfile() - await seedDefaultCwd() - await callbacksRef.current.refreshHermesConfig().catch(() => undefined) - await callbacksRef.current.refreshSessions().catch(() => undefined) + await Promise.all([ + seedDefaultCwd(), + callbacksRef.current.refreshHermesConfig().catch(() => undefined), + callbacksRef.current.refreshSessions().catch(() => undefined) + ]) completeDesktopBoot() bootCompleted = true } catch (err) { @@ -460,6 +464,11 @@ export function useGatewayBoot({ return } + // Profile adoption must land first: refreshSessions scopes its fetch by + // $profileScope ← $activeGatewayProfile. The remaining three fetches + // (cwd seed, config, sessions) are independent REST calls — running + // them serially added their sum to time-to-populated-sidebar when only + // the max is needed. await adoptPrimaryProfile() setDesktopBootStep({ @@ -467,20 +476,17 @@ export function useGatewayBoot({ message: translateNow('boot.steps.loadingSettings'), progress: 97 }) - await seedDefaultCwd() - await callbacksRef.current.refreshHermesConfig() + await Promise.all([ + seedDefaultCwd(), + callbacksRef.current.refreshHermesConfig(), + callbacksRef.current.refreshSessions() + ]) if (cancelled) { return } - setDesktopBootStep({ - phase: 'renderer.sessions', - message: translateNow('boot.steps.loadingSessions'), - progress: 99 - }) - await callbacksRef.current.refreshSessions() completeDesktopBoot() bootCompleted = true } catch (err) { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 57fc89f40d03..fe8cbe5edd8e 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -1,5 +1,5 @@ import type { QueryClient } from '@tanstack/react-query' -import { type MutableRefObject, useCallback, useRef } from 'react' +import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream' import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' @@ -26,6 +26,8 @@ import { followActiveSessionCwd } from '@/store/projects' import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' import { $currentCwd, + $currentModel, + $currentProvider, sessionMatchesStoredId, setCurrentBranch, setCurrentCwd, @@ -77,6 +79,7 @@ interface GatewayEventDeps { queryClient: QueryClient refreshHermesConfig: () => Promise sessionInterrupted: (sessionId: string) => boolean + sessionStateByRuntimeIdRef: MutableRefObject> updateSessionState: ( sessionId: string, updater: (state: ClientSessionState) => ClientSessionState, @@ -105,12 +108,48 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { queryClient, refreshHermesConfig, sessionInterrupted, + sessionStateByRuntimeIdRef, updateSessionState, upsertToolCall } = deps const unscopedStreamSessionIdRef = useRef(null) + // session.info arrives in bursts (agent build ready + turn end + title / + // MCP / compress edges within the same second). Each used to fire its own + // refreshHermesConfig — two REST calls (config + defaults) per event, per + // turn, including for BACKGROUND sessions whose values the fetch can't even + // apply. Coalesce to one trailing fetch per burst; the caller gates on + // `apply` so background traffic doesn't schedule anything. + const configRefreshTimerRef = useRef(null) + + const scheduleConfigRefresh = useCallback(() => { + if (configRefreshTimerRef.current !== null) { + return + } + + if (typeof window === 'undefined') { + void refreshHermesConfig() + + return + } + + configRefreshTimerRef.current = window.setTimeout(() => { + configRefreshTimerRef.current = null + void refreshHermesConfig() + }, 300) + }, [refreshHermesConfig]) + + useEffect( + () => () => { + if (configRefreshTimerRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(configRefreshTimerRef.current) + configRefreshTimerRef.current = null + } + }, + [] + ) + return useCallback( (event: RpcEvent) => { const payload = event.payload as GatewayEventPayload | undefined @@ -151,6 +190,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const modelChanged = typeof payload?.model === 'string' const providerChanged = typeof payload?.provider === 'string' const runningChanged = typeof payload?.running === 'boolean' + // The backend stamps model/provider (as strings) on EVERY session.info, + // so the presence flags above are true on every heartbeat/turn edge — + // fine for the cheap atom writes below (nanostores skips identical + // values), but they also drove queryClient.invalidateQueries, refetching + // the model-options provider catalog once or twice per turn for a model + // that never changed. Only a genuine VALUE change (vs the session's own + // cached runtime state, captured before the state patch below applies; + // composer atoms as the fallback for an uncached session) invalidates. + const knownState = sessionId ? sessionStateByRuntimeIdRef.current.get(sessionId) : undefined + const modelValueChanged = modelChanged && payload!.model !== (knownState?.model ?? $currentModel.get()) + const providerValueChanged = + providerChanged && payload!.provider !== (knownState?.provider ?? $currentProvider.get()) // Config is profile-scoped, but session.info also arrives for background // sessions. Only an active-session event from the currently active @@ -292,11 +343,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (apply) { reportInstallMethodWarning(payload?.install_warning) + // Config refetch is only meaningful for the foreground context — + // everything refreshHermesConfig applies is either active-session + // guarded or a composer/global pref. Background sessions' heartbeats + // used to trigger it too (two REST calls each, every turn). + scheduleConfigRefresh() } - void refreshHermesConfig() - - if (modelChanged || providerChanged) { + if (modelValueChanged || providerValueChanged) { void queryClient.invalidateQueries({ queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options'] }) @@ -755,8 +809,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { lastCwdInfoSessionRef, nativeSubagentSessionsRef, queryClient, - refreshHermesConfig, + scheduleConfigRefresh, sessionInterrupted, + sessionStateByRuntimeIdRef, updateSessionState, upsertToolCall ] diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 65a203a215eb..12aa158a860d 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -131,6 +131,46 @@ export function useMessageStream({ [updateSessionState] ) + // Turn-complete triggers a full sidebar refresh (recents + cron + messaging + // REST fan-out, each scanning profile state.dbs server-side) plus a + // cross-window broadcast that makes every other window do the same. Parallel + // tiles / multi-window finishing near-simultaneously used to multiply that. + // Coalesce completions into one trailing refresh per burst — a ~300ms title + // lag is invisible; the redundant aggregator scans are not. + const sessionsRefreshTimerRef = useRef(null) + + const scheduleSessionsRefresh = useCallback(() => { + if (sessionsRefreshTimerRef.current !== null) { + return + } + + const run = () => { + sessionsRefreshTimerRef.current = null + void refreshSessions().catch(() => undefined) + // Sync freshly-titled rows to other windows (e.g. main, when the turn + // ran in the pop-out). + broadcastSessionsChanged() + } + + if (typeof window === 'undefined') { + run() + + return + } + + sessionsRefreshTimerRef.current = window.setTimeout(run, 300) + }, [refreshSessions]) + + useEffect( + () => () => { + if (sessionsRefreshTimerRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(sessionsRefreshTimerRef.current) + sessionsRefreshTimerRef.current = null + } + }, + [] + ) + const queuedDeltasRef = useRef>(new Map()) const flushHandleRef = useRef(null) const lastFlushAtRef = useRef(0) @@ -444,10 +484,7 @@ export function useMessageStream({ } }) - void refreshSessions().catch(() => undefined) - // Sync the freshly-titled row to other windows (e.g. main, when the turn - // ran in the pop-out). - broadcastSessionsChanged() + scheduleSessionsRefresh() if (compactedTurnRef.current.delete(sessionId)) { shouldHydrate = false @@ -464,7 +501,7 @@ export function useMessageStream({ title: translateNow('notifications.native.turnDoneTitle') }) }, - [hydrateFromStoredSession, refreshSessions, updateSessionState] + [hydrateFromStoredSession, scheduleSessionsRefresh, updateSessionState] ) const failAssistantMessage = useCallback( @@ -526,6 +563,7 @@ export function useMessageStream({ queryClient, refreshHermesConfig, sessionInterrupted, + sessionStateByRuntimeIdRef, updateSessionState, upsertToolCall }) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/session-info-side-effects.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/session-info-side-effects.test.tsx new file mode 100644 index 000000000000..46bcd9ec3239 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/session-info-side-effects.test.tsx @@ -0,0 +1,155 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { setCurrentModel, setCurrentProvider } from '@/store/session' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +// Per-turn REST amplification guards: session.info must not refetch config for +// background sessions nor invalidate the model-options catalog when the model +// string is merely PRESENT (the backend stamps it on every event) rather than +// actually changed. message.complete must coalesce sidebar refreshes. + +const ACTIVE_SID = 'session-active' +let handleEvent: ((event: RpcEvent) => void) | null = null +let refreshHermesConfig: ReturnType Promise>> +let refreshSessions: ReturnType Promise>> +let queryClient: QueryClient + +function Harness() { + const activeSessionIdRef = useRef(ACTIVE_SID) + const sessionStateByRuntimeIdRef = useRef(new Map()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient, + refreshHermesConfig, + refreshSessions, + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const next = updater(current) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render() + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +const sessionInfo = (sessionId: string, payload: Record) => + act(() => handleEvent!({ payload, session_id: sessionId, type: 'session.info' })) + +beforeEach(() => { + handleEvent = null + refreshHermesConfig = vi.fn<() => Promise>(async () => undefined) + refreshSessions = vi.fn<() => Promise>(async () => undefined) + queryClient = new QueryClient() + setCurrentModel('') + setCurrentProvider('') +}) + +afterEach(() => { + cleanup() + setCurrentModel('') + setCurrentProvider('') + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('session.info config refetch gating', () => { + it('coalesces active-session bursts into one trailing config fetch', async () => { + // Mount under real timers (waitFor), then freeze time for the debounce. + await mountStream() + vi.useFakeTimers() + + sessionInfo(ACTIVE_SID, { model: 'm1', running: true }) + sessionInfo(ACTIVE_SID, { model: 'm1', running: false }) + sessionInfo(ACTIVE_SID, { model: 'm1', title: 't' }) + + expect(refreshHermesConfig).not.toHaveBeenCalled() + + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + + expect(refreshHermesConfig).toHaveBeenCalledTimes(1) + }) + + it('never fetches config for a background session heartbeat', async () => { + await mountStream() + vi.useFakeTimers() + + sessionInfo('session-background', { model: 'm1', running: true }) + sessionInfo('session-background', { model: 'm1', running: false }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + + expect(refreshHermesConfig).not.toHaveBeenCalled() + }) +}) + +describe('session.info model-options invalidation gating', () => { + it('skips invalidation when model/provider merely restate the known values', async () => { + await mountStream() + const invalidate = vi.spyOn(queryClient, 'invalidateQueries') + + // Seed the session's cached runtime state. + sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true }) + invalidate.mockClear() + + // Turn-end heartbeat restating the same model/provider — the pre-fix path + // invalidated (and refetched the provider catalog) on every one of these. + sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: false }) + + expect(invalidate).not.toHaveBeenCalled() + }) + + it('invalidates when the session model actually changes', async () => { + await mountStream() + const invalidate = vi.spyOn(queryClient, 'invalidateQueries') + + sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true }) + invalidate.mockClear() + + sessionInfo(ACTIVE_SID, { model: 'm2', provider: 'p1', running: true }) + + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['model-options', ACTIVE_SID] }) + }) +}) + +describe('message.complete sidebar refresh coalescing', () => { + it('collapses near-simultaneous completions into one refresh', async () => { + await mountStream() + vi.useFakeTimers() + + act(() => handleEvent!({ payload: { text: 'a' }, session_id: 's1', type: 'message.complete' })) + act(() => handleEvent!({ payload: { text: 'b' }, session_id: 's2', type: 'message.complete' })) + + expect(refreshSessions).not.toHaveBeenCalled() + + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + + expect(refreshSessions).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx new file mode 100644 index 000000000000..908d1d88f214 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -0,0 +1,137 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { SessionInfo } from '@/hermes' +import { $sessions, $sessionsLoading, setSessions, setSessionsLoading } from '@/store/session' + +import { useSessionListActions } from './use-session-list-actions' + +// Sidebar refresh hygiene: a content-identical refresh (turn complete, +// cross-window broadcast, reconnect) must not replace $sessions' array +// identity — that identity is the dependency for every sidebar memo — and +// must not flicker the loading flag over an already-populated list. + +const row = (id: string, over: Partial = {}): SessionInfo => + ({ + ended_at: null, + id, + input_tokens: 0, + is_active: false, + last_active: 1000, + message_count: 3, + model: 'm', + output_tokens: 0, + preview: 'hey', + profile: 'default', + source: 'desktop', + started_at: 900, + title: `Chat ${id}`, + ...over + }) as SessionInfo + +const listAllProfileSessions = vi.fn() + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + getCronJobs: vi.fn(async () => []), + listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args) +})) + +beforeEach(() => { + listAllProfileSessions.mockReset() + setSessions([]) + setSessionsLoading(false) +}) + +afterEach(() => { + setSessions([]) + setSessionsLoading(false) +}) + +describe('refreshSessions identity + loading hygiene', () => { + it('keeps the previous $sessions array when the refresh is content-identical', async () => { + const rows = [row('a'), row('b')] + listAllProfileSessions.mockResolvedValue({ sessions: rows, total: 2, profile_totals: { default: 2 } }) + + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + await act(async () => { + await result.current.refreshSessions() + }) + + const first = $sessions.get() + expect(first.map(s => s.id)).toEqual(['a', 'b']) + + // Second refresh returns fresh (but equal) row objects, as the API does. + listAllProfileSessions.mockResolvedValue({ + sessions: [row('a'), row('b')], + total: 2, + profile_totals: { default: 2 } + }) + + await act(async () => { + await result.current.refreshSessions() + }) + + expect($sessions.get()).toBe(first) + }) + + it('swaps the array when rows actually changed', async () => { + listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} }) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + await act(async () => { + await result.current.refreshSessions() + }) + + const first = $sessions.get() + + listAllProfileSessions.mockResolvedValue({ + sessions: [row('a', { last_active: 2000, title: 'Renamed' })], + total: 1, + profile_totals: {} + }) + + await act(async () => { + await result.current.refreshSessions() + }) + + expect($sessions.get()).not.toBe(first) + expect($sessions.get()[0].title).toBe('Renamed') + }) + + it('does not flicker the loading flag over a populated list', async () => { + listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} }) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + await act(async () => { + await result.current.refreshSessions() + }) + + const loadingStates: boolean[] = [] + const off = $sessionsLoading.subscribe(value => loadingStates.push(value)) + + await act(async () => { + await result.current.refreshSessions() + }) + + off() + // Only the initial subscribe emission — no true/false churn per refresh. + expect(loadingStates).toEqual([false]) + }) + + it('still shows loading for the initial (empty-list) fetch', async () => { + listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} }) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + const loadingStates: boolean[] = [] + const off = $sessionsLoading.subscribe(value => loadingStates.push(value)) + + await act(async () => { + await result.current.refreshSessions() + }) + + off() + expect(loadingStates).toEqual([false, true, false]) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index f3c88be43aa0..4005233da3f1 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -153,7 +153,15 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg const refreshSessions = useCallback(async () => { const requestId = refreshSessionsRequestRef.current + 1 refreshSessionsRequestRef.current = requestId - setSessionsLoading(true) + // The loading flag exists to drive the initial skeletons (they only render + // while the list is empty). Turn-complete / reconnect refreshes over a + // populated list used to flip it true→false anyway, churning every + // $sessionsLoading subscriber twice per turn for no visible change. + const showLoading = $sessions.get().length === 0 + + if (showLoading) { + setSessionsLoading(true) + } try { const limit = $sessionsLimit.get() @@ -176,12 +184,27 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg }) if (refreshSessionsRequestRef.current === requestId) { - setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) + // Signature-gate the swap (same pattern as cron/messaging): a refresh + // that returns content-identical rows must keep the previous array + // identity, or every sidebar memo keyed on $sessions recomputes and the + // whole list re-renders once per turn/broadcast for nothing. + setSessions(prev => { + const next = mergeSessionPage(prev, result.sessions, sessionsToKeep()) + + return sameCronSignature(prev, next) ? prev : next + }) setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) - setSessionProfileTotals(result.profile_totals ?? {}) + setSessionProfileTotals(prev => { + const next = result.profile_totals ?? {} + const prevKeys = Object.keys(prev) + + return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key]) + ? prev + : next + }) } } finally { - if (refreshSessionsRequestRef.current === requestId) { + if (showLoading && refreshSessionsRequestRef.current === requestId) { setSessionsLoading(false) } }