diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx
index cfa41938fdfa..5edd193dbca2 100644
--- a/apps/desktop/src/app/contrib/controller.tsx
+++ b/apps/desktop/src/app/contrib/controller.tsx
@@ -5,6 +5,7 @@ import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } f
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
+import { IdleMount } from '@/components/idle-mount'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, split } from '@/components/pane-shell/tree/model'
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
@@ -90,6 +91,10 @@ import { ContribWiring, WiredPane } from './wiring'
// ONE render identity for the workspace pane — syncWorkspaceTitle re-registers
// the contribution (new title) and a fresh closure would remount the chat.
const renderWorkspacePane = () =>
+
+// Boot-hidden panes mount behind display:none (instant-toggle contract) — defer
+// them to idle so they're off the first-paint path, warm before reveal.
+const idle = (node: ReactElement) => {node}
// The main tab carries the same session context menu as tile tabs (targets
// the loaded primary session; no menu on a fresh draft).
const wrapWorkspaceTab = (tab: ReactElement) => {tab}
@@ -182,7 +187,7 @@ registry.registerMany([
minWidth: FILE_BROWSER_MIN_WIDTH,
maxWidth: FILE_BROWSER_MAX_WIDTH
},
- render: () =>
+ render: () => idle()
},
{
id: 'preview',
@@ -200,7 +205,7 @@ registry.registerMany([
minWidth: PREVIEW_RAIL_MIN_WIDTH,
maxWidth: PREVIEW_RAIL_MAX_WIDTH
},
- render: () =>
+ render: () => idle()
},
{
id: 'review',
@@ -216,7 +221,7 @@ registry.registerMany([
minWidth: FILE_BROWSER_MIN_WIDTH,
maxWidth: FILE_BROWSER_MAX_WIDTH
},
- render: () =>
+ render: () => idle()
},
{
// Optional chrome — in NO default layout. Adoption stacks it with the
@@ -227,7 +232,7 @@ registry.registerMany([
// revealOnPreset: the Quad layout places logs, so applying it turns the
// logs pane on (like a ⌘K "Toggle logs") instead of leaving it collapsed.
data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true },
- render: () =>
+ render: () => idle()
}
])
diff --git a/apps/desktop/src/components/idle-mount.test.tsx b/apps/desktop/src/components/idle-mount.test.tsx
new file mode 100644
index 000000000000..db51c1578c83
--- /dev/null
+++ b/apps/desktop/src/components/idle-mount.test.tsx
@@ -0,0 +1,50 @@
+import { act, cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { IdleMount } from './idle-mount'
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+describe('IdleMount', () => {
+ it('mounts eagerly where requestIdleCallback is unavailable', () => {
+ vi.stubGlobal('requestIdleCallback', undefined)
+
+ render(
+
+ child
+
+ )
+
+ expect(screen.getByText('child')).toBeTruthy()
+ })
+
+ it('defers the child to idle, then keeps it mounted', () => {
+ let fire: (() => void) | null = null
+
+ const ric = vi.fn((cb: () => void) => {
+ fire = cb
+
+ return 1
+ })
+
+ vi.stubGlobal('requestIdleCallback', ric)
+ vi.stubGlobal('cancelIdleCallback', vi.fn())
+
+ render(
+
+ child
+
+ )
+
+ // Not on the first-paint path — nothing rendered until the browser is idle.
+ expect(screen.queryByText('child')).toBeNull()
+ expect(ric).toHaveBeenCalledOnce()
+
+ act(() => fire?.())
+
+ expect(screen.getByText('child')).toBeTruthy()
+ })
+})
diff --git a/apps/desktop/src/components/idle-mount.tsx b/apps/desktop/src/components/idle-mount.tsx
new file mode 100644
index 000000000000..9a15ce84b508
--- /dev/null
+++ b/apps/desktop/src/components/idle-mount.tsx
@@ -0,0 +1,28 @@
+import { type ReactNode, useEffect, useState } from 'react'
+
+/**
+ * Mounts `children` only once the browser goes idle (or `timeout` ms elapse),
+ * then keeps them mounted for good. Lifts non-critical, boot-hidden surfaces
+ * (display:none panes: files/preview/review/logs) off the first-paint critical
+ * path with ZERO visible change — idle fires within a frame of first paint, so
+ * a hidden pane is warm long before the user can reveal it, preserving the
+ * "toggle back is instant" contract while shrinking cold-start app-mount.
+ *
+ * Degrades to eager mount where requestIdleCallback is absent (jsdom/tests,
+ * older webviews), so there's no behavioral fork to reason about there.
+ */
+export function IdleMount({ children, timeout = 2000 }: { children: ReactNode; timeout?: number }) {
+ const [ready, setReady] = useState(typeof requestIdleCallback !== 'function')
+
+ useEffect(() => {
+ if (ready) {
+ return undefined
+ }
+
+ const id = requestIdleCallback(() => setReady(true), { timeout })
+
+ return () => cancelIdleCallback(id)
+ }, [ready, timeout])
+
+ return ready ? <>{children}> : null
+}