Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7409,12 +7409,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))
})
}

Expand Down
28 changes: 17 additions & 11 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -460,27 +464,29 @@ 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({
phase: 'renderer.config',
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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -26,6 +26,8 @@ import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
$currentCwd,
$currentModel,
$currentProvider,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
Expand Down Expand Up @@ -77,6 +79,7 @@ interface GatewayEventDeps {
queryClient: QueryClient
refreshHermesConfig: () => Promise<void>
sessionInterrupted: (sessionId: string) => boolean
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
Expand Down Expand Up @@ -105,12 +108,48 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
queryClient,
refreshHermesConfig,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
} = deps

const unscopedStreamSessionIdRef = useRef<string | null>(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 | number>(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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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']
})
Expand Down Expand Up @@ -755,8 +809,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
lastCwdInfoSessionRef,
nativeSubagentSessionsRef,
queryClient,
refreshHermesConfig,
scheduleConfigRefresh,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
]
Expand Down
48 changes: 43 additions & 5 deletions apps/desktop/src/app/session/hooks/use-message-stream/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | number>(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<Map<string, QueuedStreamDeltas>>(new Map())
const flushHandleRef = useRef<number | null>(null)
const lastFlushAtRef = useRef<number>(0)
Expand Down Expand Up @@ -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
Expand All @@ -464,7 +501,7 @@ export function useMessageStream({
title: translateNow('notifications.native.turnDoneTitle')
})
},
[hydrateFromStoredSession, refreshSessions, updateSessionState]
[hydrateFromStoredSession, scheduleSessionsRefresh, updateSessionState]
)

const failAssistantMessage = useCallback(
Expand Down Expand Up @@ -526,6 +563,7 @@ export function useMessageStream({
queryClient,
refreshHermesConfig,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
})
Expand Down
Loading
Loading