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 +}