From 6d0820ba689bebe88489e8e1ca89873529f62142 Mon Sep 17 00:00:00 2001 From: Myles Scolnick Date: Thu, 21 May 2026 13:06:32 -0400 Subject: [PATCH] perf(chrome): preload panel chunks on hover/focus Wrap all sidebar/dev-panel lazy imports with reactLazyWithPreload and prefetch the corresponding chunk when the user hovers or focuses the icon/tab. On mount, idle-preload the persisted selected panel(s) if their parent was last left open. Adds a theme-matched skeleton for the terminal panel so first open isn't a blank flash. --- .../editor/chrome/wrapper/app-chrome.tsx | 143 +++++++++++------- .../editor/chrome/wrapper/lazy-panels.ts | 81 ++++++++++ .../editor/chrome/wrapper/sidebar.tsx | 21 ++- 3 files changed, 190 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/editor/chrome/wrapper/lazy-panels.ts diff --git a/frontend/src/components/editor/chrome/wrapper/app-chrome.tsx b/frontend/src/components/editor/chrome/wrapper/app-chrome.tsx index aa4555502d5..832ff53d4ca 100644 --- a/frontend/src/components/editor/chrome/wrapper/app-chrome.tsx +++ b/frontend/src/components/editor/chrome/wrapper/app-chrome.tsx @@ -33,6 +33,26 @@ import { ErrorBoundary } from "../../boundary/ErrorBoundary"; import { raf2 } from "../../navigation/focus-utils"; import { ContextAwarePanel } from "../panels/context-aware-panel/context-aware-panel"; import { PanelSectionProvider } from "../panels/panel-context"; +import { useTheme } from "@/theme/useTheme"; +import { + LazyAgentPanel, + LazyCachePanel, + LazyChatPanel, + LazyDependencyGraphPanel, + LazyDocumentationPanel, + LazyErrorsPanel, + LazyFileExplorerPanel, + LazyLogsPanel, + LazyOutlinePanel, + LazyPackagesPanel, + LazyScratchpadPanel, + LazySecretsPanel, + LazySessionPanel, + LazySnippetsPanel, + LazyTerminal, + LazyTracingPanel, + PANEL_PRELOADERS, +} from "./lazy-panels"; import { panelLayoutAtom, useChromeActions, useChromeState } from "../state"; import { isPanelHidden, @@ -49,33 +69,26 @@ import { useAiPanelTab } from "./useAiPanel"; import { useDependencyPanelTab } from "./useDependencyPanelTab"; import { handleDragging } from "./utils"; -const LazyTerminal = React.lazy(() => import("@/components/terminal/terminal")); -const LazyChatPanel = React.lazy(() => import("@/components/chat/chat-panel")); -const LazyAgentPanel = React.lazy( - () => import("@/components/chat/acp/agent-panel"), -); -const LazyDependencyGraphPanel = React.lazy( - () => import("@/components/editor/chrome/panels/dependency-graph-panel"), -); -const LazySessionPanel = React.lazy(() => import("../panels/session-panel")); -const LazyDocumentationPanel = React.lazy( - () => import("../panels/documentation-panel"), -); -const LazyErrorsPanel = React.lazy(() => import("../panels/error-panel")); -const LazyFileExplorerPanel = React.lazy( - () => import("../panels/file-explorer-panel"), -); -const LazyLogsPanel = React.lazy(() => import("../panels/logs-panel")); -const LazyOutlinePanel = React.lazy(() => import("../panels/outline-panel")); -const LazyPackagesPanel = React.lazy(() => import("../panels/packages-panel")); -const LazyScratchpadPanel = React.lazy( - () => import("../panels/scratchpad-panel"), -); -const LazySecretsPanel = React.lazy(() => import("../panels/secrets-panel")); -const LazySnippetsPanel = React.lazy(() => import("../panels/snippets-panel")); -const LazyTracingPanel = React.lazy(() => import("../panels/tracing-panel")); -const LazyCachePanel = React.lazy(() => import("../panels/cache-panel")); - +// Placeholder that matches the eventual xterm theme background so the +// transition into the loaded terminal is seamless rather than a blank flash. +const TerminalSkeleton: React.FC = () => { + const { theme } = useTheme(); + const isDark = theme === "dark"; + return ( +
+ Starting terminal + +
+ ); +}; export const AppChrome: React.FC = ({ children }) => { const { isSidebarOpen, @@ -94,6 +107,26 @@ export const AppChrome: React.FC = ({ children }) => { // Subscribe to capabilities to re-render when they change (e.g., terminal capability) const capabilities = useAtomValue(capabilitiesAtom); + // On mount, idle-preload whichever panels the user had open at last unload, + // so the first interaction with the sidebar/dev panel doesn't hit a cold + // chunk fetch. Runs once. + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const schedule = + typeof window !== "undefined" && + typeof window.requestIdleCallback === "function" + ? (cb: () => void) => window.requestIdleCallback(cb, { timeout: 2000 }) + : (cb: () => void) => setTimeout(cb, 300); + schedule(() => { + if (isSidebarOpen && selectedPanel) { + PANEL_PRELOADERS[selectedPanel]?.(); + } + if (isDeveloperPanelOpen && selectedDeveloperPanelTab) { + PANEL_PRELOADERS[selectedDeveloperPanelTab]?.(); + } + }); + }, []); + // Convert current developer panel items to PanelDescriptors // Filter out hidden panels (e.g., terminal when capability is not available) const devPanelItems = useMemo(() => { @@ -254,32 +287,34 @@ export const AppChrome: React.FC = ({ children }) => { const renderAiPanel = () => { if (agentsEnabled && aiPanelTab === "agents") { - return ; + return ; } - return ; + return ; }; const SIDEBAR_PANELS: Record = { - files: , - variables: , - dependencies: , - packages: , - outline: , - documentation: , - snippets: , + files: , + variables: , + dependencies: , + packages: , + outline: , + documentation: , + snippets: , ai: renderAiPanel(), - errors: , - scratchpad: , - tracing: , - secrets: , - logs: , + errors: , + scratchpad: , + tracing: , + secrets: , + logs: , terminal: ( - setIsSidebarOpen(false)} - /> + }> + setIsSidebarOpen(false)} + /> + ), - cache: , + cache: , }; const helpPaneBody = ( @@ -412,12 +447,14 @@ export const AppChrome: React.FC = ({ children }) => { const DEVELOPER_PANELS: Record = { ...SIDEBAR_PANELS, terminal: ( - setIsDeveloperPanelOpen(false)} - /> + }> + setIsDeveloperPanelOpen(false)} + /> + ), }; @@ -480,6 +517,8 @@ export const AppChrome: React.FC = ({ children }) => { ? "bg-muted" : "hover:bg-muted/50", )} + onMouseEnter={PANEL_PRELOADERS[panel.type]} + onFocus={PANEL_PRELOADERS[panel.type]} > import("@/components/terminal/terminal"), +); +export const LazyChatPanel = reactLazyWithPreload( + () => import("@/components/chat/chat-panel"), +); +export const LazyAgentPanel = reactLazyWithPreload( + () => import("@/components/chat/acp/agent-panel"), +); +export const LazyDependencyGraphPanel = reactLazyWithPreload( + () => import("../panels/dependency-graph-panel"), +); +export const LazySessionPanel = reactLazyWithPreload( + () => import("../panels/session-panel"), +); +export const LazyDocumentationPanel = reactLazyWithPreload( + () => import("../panels/documentation-panel"), +); +export const LazyErrorsPanel = reactLazyWithPreload( + () => import("../panels/error-panel"), +); +export const LazyFileExplorerPanel = reactLazyWithPreload( + () => import("../panels/file-explorer-panel"), +); +export const LazyLogsPanel = reactLazyWithPreload( + () => import("../panels/logs-panel"), +); +export const LazyOutlinePanel = reactLazyWithPreload( + () => import("../panels/outline-panel"), +); +export const LazyPackagesPanel = reactLazyWithPreload( + () => import("../panels/packages-panel"), +); +export const LazyScratchpadPanel = reactLazyWithPreload( + () => import("../panels/scratchpad-panel"), +); +export const LazySecretsPanel = reactLazyWithPreload( + () => import("../panels/secrets-panel"), +); +export const LazySnippetsPanel = reactLazyWithPreload( + () => import("../panels/snippets-panel"), +); +export const LazyTracingPanel = reactLazyWithPreload( + () => import("../panels/tracing-panel"), +); +export const LazyCachePanel = reactLazyWithPreload( + () => import("../panels/cache-panel"), +); + +// Preloader registry: hovering an icon/tab calls into this map to warm the +// corresponding chunk. Two panel types (chat and agents) share the "ai" slot, +// so we preload both. +export const PANEL_PRELOADERS: Record void> = { + files: LazyFileExplorerPanel.preload, + variables: LazySessionPanel.preload, + dependencies: LazyDependencyGraphPanel.preload, + packages: LazyPackagesPanel.preload, + outline: LazyOutlinePanel.preload, + documentation: LazyDocumentationPanel.preload, + snippets: LazySnippetsPanel.preload, + ai: () => { + LazyChatPanel.preload(); + LazyAgentPanel.preload(); + }, + errors: LazyErrorsPanel.preload, + scratchpad: LazyScratchpadPanel.preload, + tracing: LazyTracingPanel.preload, + secrets: LazySecretsPanel.preload, + logs: LazyLogsPanel.preload, + terminal: LazyTerminal.preload, + cache: LazyCachePanel.preload, +}; diff --git a/frontend/src/components/editor/chrome/wrapper/sidebar.tsx b/frontend/src/components/editor/chrome/wrapper/sidebar.tsx index ae80e0a4f8d..68c229902c1 100644 --- a/frontend/src/components/editor/chrome/wrapper/sidebar.tsx +++ b/frontend/src/components/editor/chrome/wrapper/sidebar.tsx @@ -21,6 +21,7 @@ import { PANELS, type PanelDescriptor, } from "../types"; +import { PANEL_PRELOADERS } from "./lazy-panels"; export const Sidebar: React.FC = () => { const { selectedPanel, selectedDeveloperPanelTab, isSidebarOpen } = @@ -143,6 +144,7 @@ export const Sidebar: React.FC = () => { {panel.type === "errors" ? ( @@ -206,8 +208,10 @@ const SidebarItem: React.FC< tooltip: React.ReactNode; className?: string; onClick?: () => void; + /** Fired on hover or focus — used to preload the panel's chunk. */ + onPreloadHint?: () => void; }> -> = ({ children, tooltip, selected, className, onClick }) => { +> = ({ children, tooltip, selected, className, onClick, onPreloadHint }) => { const itemClassName = cn( "flex items-center p-2 text-sm mx-px shadow-inset font-mono rounded", !selected && "hover:bg-(--sage-3)", @@ -218,11 +222,22 @@ const SidebarItem: React.FC< // Render as div when not clickable (e.g., inside ReorderableList) // This avoids nested interactive elements which break react-aria's drag behavior const content = onClick ? ( - ) : ( -
{children}
+
+ {children} +
); return (