Skip to content
Closed
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
11 changes: 11 additions & 0 deletions apps/desktop/src/app/contrib/route-loaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'

import { shouldWarmCommonRoutes } from './route-loaders'

describe('common route warmup eligibility', () => {
it('runs only in the connected primary window', () => {
expect(shouldWarmCommonRoutes(true, false)).toBe(true)
expect(shouldWarmCommonRoutes(false, false)).toBe(false)
expect(shouldWarmCommonRoutes(true, true)).toBe(false)
})
})
18 changes: 18 additions & 0 deletions apps/desktop/src/app/contrib/route-loaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const loadArtifactsView = () => import('../artifacts')
export const loadCronView = () => import('../cron')
export const loadMessagingView = () => import('../messaging')
export const loadSettingsView = () => import('../settings')
export const loadSkillsView = () => import('../skills')

/** Common first-click routes, ordered from lighter/more frequent to heavier. */
export const COMMON_ROUTE_WARMUP_LOADERS = [
loadMessagingView,
loadArtifactsView,
loadCronView,
loadSettingsView,
loadSkillsView
] as const

export function shouldWarmCommonRoutes(gatewayOpen: boolean, auxiliaryWindow: boolean): boolean {
return gatewayOpen && !auxiliaryWindow
}
7 changes: 4 additions & 3 deletions apps/desktop/src/app/contrib/surfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ import { StatusbarControls } from '../shell/statusbar-controls'

import { latestChatActions, latestSidebarActions } from './latest-actions'
import { setStatusbarItemGroup, useStatusbarContributions } from './panes'
import { loadArtifactsView, loadMessagingView, loadSkillsView } from './route-loaders'
import type { SidebarActions, WiringActions } from './types'

// Same lazy-view split as DesktopController β€” pages load on demand. The
// full-page views the workspace route table mounts live here; overlay views
// (agents/settings/…) are the controller's and stay in wiring.tsx.
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
const ArtifactsView = lazy(async () => ({ default: (await loadArtifactsView()).ArtifactsView }))
const MessagingView = lazy(async () => ({ default: (await loadMessagingView()).MessagingView }))
const SkillsView = lazy(async () => ({ default: (await loadSkillsView()).SkillsView }))

export function LegacySessionRedirect() {
const { sessionId } = useParams()
Expand Down
22 changes: 20 additions & 2 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { TipHost } from '@/components/tips'
import { emitGatewayEvent } from '@/contrib/events'
import { getLatestSessionMessages } from '@/hermes'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { scheduleIdleWarmup } from '@/lib/idle-warmup'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
import { activateWakeIndicator } from '@/lib/wake-indicator'
Expand Down Expand Up @@ -149,6 +150,7 @@ import { useQuickEntryBridge } from './hooks/use-quick-entry-bridge'
import { useSessionTileDelegate } from './hooks/use-session-tile-delegate'
import { McpInstallDeepLinkDialog } from './mcp-install-deeplink-dialog'
import { $restartPreviewServer, useTitlebarToolContributions } from './panes'
import { COMMON_ROUTE_WARMUP_LOADERS, loadCronView, loadSettingsView, shouldWarmCommonRoutes } from './route-loaders'
import { createSessionRpcDispatcher } from './session-rpc-dispatcher'
import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces'
import type { WiringActions, WiringApi } from './types'
Expand All @@ -158,10 +160,10 @@ import type { WiringActions, WiringApi } from './types'
// ChatRoutesSurface's and live in ./surfaces.
const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView }))
const CronView = lazy(async () => ({ default: (await import('../cron')).CronView }))
const CronView = lazy(async () => ({ default: (await loadCronView()).CronView }))
const WebhooksView = lazy(async () => ({ default: (await import('../webhooks')).WebhooksView }))
const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView }))
const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView }))
const SettingsView = lazy(async () => ({ default: (await loadSettingsView()).SettingsView }))
const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView }))

// Surfaces (the four wired panes), the render context + WiredPane, and the
Expand Down Expand Up @@ -193,6 +195,22 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const cronReviewRequest = useStore($cronReviewRequest)
const currentCwd = useStore($currentCwd)

useEffect(() => {
// Secondary/HUD/browser windows do not own normal navigation. Preloading
// the full primary route set there wastes CPU and memory without making a
// user interaction faster.
if (!shouldWarmCommonRoutes(gatewayState === 'open', isAuxiliaryWindow())) {
return
}

// Warm only common route code, never route data. One shared queue keeps
// parsing serialized so background preparation cannot compete with chat.
return scheduleIdleWarmup(COMMON_ROUTE_WARMUP_LOADERS, {
gapMs: 350,
initialDelayMs: 1_500
})
}, [gatewayState])

// eslint-disable-next-line no-restricted-syntax -- one-shot request-seen sentinel, not an atom mirror
useEffect(() => {
if (billingSettingsRequest === billingSettingsSeenRef.current) {
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/lib/idle-warmup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'

import { scheduleIdleWarmup } from './idle-warmup'

afterEach(() => {
vi.useRealTimers()
})

describe('scheduleIdleWarmup', () => {
it('waits for each module before scheduling the next one', async () => {
vi.useFakeTimers()
const order: string[] = []
let finishFirst: () => void = () => undefined

const first = new Promise<void>(resolve => {
finishFirst = resolve
})

scheduleIdleWarmup(
[
async () => {
order.push('messaging')
await first
},
async () => void order.push('artifacts'),
async () => void order.push('capabilities')
],
{ gapMs: 20, initialDelayMs: 100 }
)

await vi.advanceTimersByTimeAsync(100)
expect(order).toEqual(['messaging'])

await vi.advanceTimersByTimeAsync(1_000)
expect(order).toEqual(['messaging'])

finishFirst()
await vi.runAllTimersAsync()
expect(order).toEqual(['messaging', 'artifacts', 'capabilities'])
})

it('continues after a module fails to load', async () => {
vi.useFakeTimers()
const next = vi.fn(async () => undefined)

scheduleIdleWarmup([async () => Promise.reject(new Error('chunk unavailable')), next], {
gapMs: 20,
initialDelayMs: 100
})
await vi.runAllTimersAsync()

expect(next).toHaveBeenCalledOnce()
})

it('cancels work that has not started', async () => {
vi.useFakeTimers()
const loader = vi.fn(async () => undefined)
const cancel = scheduleIdleWarmup([loader], { initialDelayMs: 100 })

cancel()
await vi.runAllTimersAsync()

expect(loader).not.toHaveBeenCalled()
})
})
71 changes: 71 additions & 0 deletions apps/desktop/src/lib/idle-warmup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
type ModuleLoader = () => Promise<unknown>

interface IdleWarmupOptions {
gapMs?: number
idleTimeoutMs?: number
initialDelayMs?: number
}

/**
* Load code chunks one at a time after the foreground settles. Dynamic imports
* are cached by the module loader, so opening a warmed surface avoids its
* first-click parse/load pause without putting every route on the startup path.
*/
export function scheduleIdleWarmup(loaders: readonly ModuleLoader[], options: IdleWarmupOptions = {}): () => void {
const initialDelayMs = options.initialDelayMs ?? 1_500
const gapMs = options.gapMs ?? 250
const idleTimeoutMs = options.idleTimeoutMs ?? 5_000
let cancelled = false
let idleHandle: null | number = null
let timeoutHandle: null | number = null
let index = 0

const schedule = (delayMs: number) => {
timeoutHandle = window.setTimeout(() => {
timeoutHandle = null

const run = () => {
idleHandle = null

if (cancelled || index >= loaders.length) {
return
}

const loader = loaders[index]

index += 1

void Promise.resolve()
.then(loader)
.catch(() => undefined)
.finally(() => {
if (!cancelled && index < loaders.length) {
schedule(gapMs)
}
})
}

if (window.requestIdleCallback) {
idleHandle = window.requestIdleCallback(run, { timeout: idleTimeoutMs })
} else {
run()
}
}, delayMs)
}

if (loaders.length > 0) {
schedule(initialDelayMs)
}

return () => {
cancelled = true

if (timeoutHandle !== null) {
window.clearTimeout(timeoutHandle)
}

if (idleHandle !== null) {
window.cancelIdleCallback(idleHandle)
}
}
}
Loading