From bc380e6e1b12d87d337a72c80a9a0333875bfd53 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Fri, 7 Aug 2026 09:01:31 +0300 Subject: [PATCH] perf: cut cold-start download 58% and startup heap 22% via measured chunk-graph fixes (#2742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): update session-switch-resync test to current handleEvent/setSessionTodos signatures Co-authored-by: Serhii Dziupin * perf(build): split Shiki grammars/themes, CodeMirror legacy modes, and @pierre/diffs into on-demand chunks Merging @shikijs/langs into one manual vendor chunk made the first language request download every grammar (7.4 MB raw / 1 MB gzip). Letting Rollup split these packages per dynamically imported module downloads only the languages, themes, and modes actually used — matching how the worker build already behaves. @pierre/diffs is split the same way so its pure patch parser (used by the eager tool renderer) no longer drags the Shiki-importing render stack into the startup graph. Co-authored-by: Serhii Dziupin * perf(ui): restore lazy heavy views and stop eager settings-graph loading - MainLayout: DiffView/FilesView/GitView/PlanView return to lazyWithChunkRecovery (they were silently made static in 2031e3b4 while their Suspense wrappers remained), keeping the CodeMirror and @pierre/diffs stacks out of startup. - ContextPanel: same lazy treatment for its Diff/Files/Git/Plan/Walkthrough tabs, with null Suspense fallbacks. - CommandPalette imported getSettingsNavIcon from SettingsView, statically pulling the entire settings surface (SkillsPage -> CodeMirrorEditor -> vim mode, theme registry -> @pierre/diffs) into the eager graph; the helper now lives in lib/settings/metadata. - The windowed SettingsWindow mounts only after its first open: rendering the lazy component closed made React fetch the SettingsView chunk graph at startup. Co-authored-by: Serhii Dziupin * perf(ui): keep @pierre/diffs + Shiki out of the eager chat graph and defer diff worker warmup - DiffWorkerProvider no longer statically imports @pierre/diffs/worker or the theme registry, and no longer spawns 3 workers plus a main-thread shared highlighter during mount. Pools are created on demand through a dynamic module load, warmed via requestIdleCallback after startup settles, and useWorkerPool notifies consumers when a pool becomes available. - ToolPart's rich diff preview moves to lazily loaded ToolPartDiffPreview; the plain-text patch (PlainDiffFallback) renders while the chunk loads, mirroring the existing error fallback. Theme registration happens during render inside the lazy module so PatchDiff never renders unregistered ids. - ChatInput mounts its lazy ToolOutputDialog only after the first attachment preview opens instead of fetching the dialog chunk on the draft screen. - getMarkdownSyntaxVars moves to a pierre-free markdownSyntaxVars module so eager code-rendering consumers stop importing the registration module. Co-authored-by: Serhii Dziupin * perf(web): load ghostty-web and Nerd Fonts on first terminal use - ghostty-web (638 KB raw JS + WASM VT) is dynamically imported when a terminal actually mounts; TerminalView stays eagerly importable for the bottom dock. - The ~2 MB of CDN Nerd Fonts are no longer preloaded and force-loaded on every cold start. index.html exposes an idempotent __openchamberEnsureNerdFonts hook; TerminalViewport requests it on mount and waits up to 2s so a cached font is in place before the glyph atlas is built, while a cold CDN fetch never blocks the terminal. Runtimes without the hook (VS Code, mobile) resolve immediately, matching their existing fallback-font behavior. Co-authored-by: Serhii Dziupin --------- Co-authored-by: Serhii Dziupin --- packages/ui/src/components/chat/ChatInput.tsx | 29 ++- .../ui/src/components/chat/DiffPreview.tsx | 2 +- .../components/chat/MarkdownRendererImpl.tsx | 3 +- .../chat/markdown/markdownSyntaxVars.ts | 31 +++ .../components/chat/markdown/markdownTheme.ts | 27 --- .../chat/message/parts/DOCUMENTATION.md | 3 +- .../chat/message/parts/PlainDiffFallback.tsx | 19 ++ .../chat/message/parts/ToolPart.tsx | 156 +------------ .../message/parts/ToolPartDiffPreview.tsx | 140 ++++++++++++ .../message/parts/VirtualizedCodeBlock.tsx | 2 +- .../components/code/WorkerHighlightedCode.tsx | 2 +- .../ui/src/components/layout/ContextPanel.tsx | 47 ++-- .../ui/src/components/layout/MainLayout.tsx | 37 +++- .../components/terminal/TerminalViewport.tsx | 37 +++- .../ui/src/components/ui/CommandPalette.tsx | 2 +- .../ui/src/components/views/SettingsView.tsx | 62 +----- .../ui/src/contexts/DiffWorkerProvider.tsx | 205 +++++++++++------- packages/ui/src/lib/settings/metadata.ts | 63 ++++++ packages/web/index.html | 54 ++--- packages/web/vite.config.ts | 22 ++ vite.config.ts | 22 ++ 21 files changed, 585 insertions(+), 380 deletions(-) create mode 100644 packages/ui/src/components/chat/markdown/markdownSyntaxVars.ts create mode 100644 packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx create mode 100644 packages/ui/src/components/chat/message/parts/ToolPartDiffPreview.tsx diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 38871269..9e055d41 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -32,7 +32,7 @@ import { } from '@/lib/chatDraftPersistence'; import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; -import ToolOutputDialog from './message/ToolOutputDialog'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { ToolPopupContent } from './message/types'; import { QueuedMessageChips } from './QueuedMessageChips'; import { AutoReviewBanner } from './AutoReviewBanner'; @@ -142,6 +142,10 @@ import { RevertedMessageDock } from './composer/ui/RevertedMessageDock'; import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip'; import { SessionGoalRow } from '@/components/chat/SessionGoalRow'; +// Lazy like in ChatMessage: a static import would pull the @pierre/diffs and +// Shiki stacks into the eager startup graph for a dialog opened on demand. +const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog')); + const MAX_VISIBLE_COMPOSER_LINES = 8; /** * Mobile grows the composer with content instead of offering a fullscreen @@ -383,6 +387,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo title: '', content: '', }); + // Mount the lazy preview dialog only after its first open; rendering it + // closed would fetch the ToolOutputDialog chunk (with the @pierre/diffs + // stack) on the draft screen before any preview is requested. + const [attachmentPreviewMounted, setAttachmentPreviewMounted] = React.useState(false); + React.useEffect(() => { + if (attachmentPreview.open) { + setAttachmentPreviewMounted(true); + } + }, [attachmentPreview.open]); const attachmentCompatibilityRef = React.useRef({ modelKey: `${currentProviderId ?? ''}/${currentModelId ?? ''}`, modalitySignature: currentModelMetadata?.modalities?.input?.slice().sort().join(',') ?? null, @@ -2786,11 +2799,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo submitting={reviewFlowSubmitting} onConfirm={handleStartReviewFlow} /> - + {attachmentPreviewMounted ? ( + + + + ) : null} {/* Single always-mounted picker input. It must NOT live inside ComposerAttachmentControls: that component mounts once per composer diff --git a/packages/ui/src/components/chat/DiffPreview.tsx b/packages/ui/src/components/chat/DiffPreview.tsx index 295475c4..c749b033 100644 --- a/packages/ui/src/components/chat/DiffPreview.tsx +++ b/packages/ui/src/components/chat/DiffPreview.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; +import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars'; import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; import { parseDiffToUnified } from './message/toolRenderers'; diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 64d0d1b5..0a8bef4c 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -20,7 +20,8 @@ import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore'; -import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme'; +import { ensureMarkdownShikiTheme } from './markdown/markdownTheme'; +import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars'; import { attachMarkdownInteractions, applyMarkdownCodeBlockWrapState, diff --git a/packages/ui/src/components/chat/markdown/markdownSyntaxVars.ts b/packages/ui/src/components/chat/markdown/markdownSyntaxVars.ts new file mode 100644 index 00000000..19de389a --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdownSyntaxVars.ts @@ -0,0 +1,31 @@ +import type { Theme } from '@/types/theme'; + +/** + * Build the `--md-syntax-*` CSS custom properties for the given app theme. + * Apply the result as inline styles on the markdown container so the static + * Shiki theme resolves to the active palette. + * + * Lives apart from `markdownTheme.ts` because that module imports + * `@pierre/diffs` for theme registration; eager consumers of these CSS vars + * (tool output, code blocks) must not pull that stack into the startup graph. + */ +export const getMarkdownSyntaxVars = (theme: Theme): Record => { + const base = theme.colors.syntax.base; + const tokens = theme.colors.syntax.tokens ?? {}; + const status = theme.colors.status; + + return { + '--md-syntax-foreground': base.foreground, + '--md-syntax-comment': base.comment, + '--md-syntax-string': base.string, + '--md-syntax-number': base.number, + '--md-syntax-keyword': base.keyword, + '--md-syntax-operator': base.operator, + '--md-syntax-function': base.function, + '--md-syntax-type': base.type, + '--md-syntax-variable': base.variable, + '--md-syntax-property': tokens.variableProperty ?? base.variable, + '--md-syntax-inserted': status.success, + '--md-syntax-deleted': status.error, + }; +}; diff --git a/packages/ui/src/components/chat/markdown/markdownTheme.ts b/packages/ui/src/components/chat/markdown/markdownTheme.ts index 30044c8e..1b40d5d1 100644 --- a/packages/ui/src/components/chat/markdown/markdownTheme.ts +++ b/packages/ui/src/components/chat/markdown/markdownTheme.ts @@ -1,5 +1,4 @@ import { registerCustomTheme, type ThemeRegistrationResolved } from '@pierre/diffs'; -import type { Theme } from '@/types/theme'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; // The static Shiki theme name. Its definition (token colors referencing @@ -27,29 +26,3 @@ export const ensureMarkdownShikiTheme = (): void => { Promise.resolve(MARKDOWN_SHIKI_THEME_DEFINITION as unknown as ThemeRegistrationResolved), ); }; - -/** - * Build the `--md-syntax-*` CSS custom properties for the given app theme. - * Apply the result as inline styles on the markdown container so the static - * Shiki theme resolves to the active palette. - */ -export const getMarkdownSyntaxVars = (theme: Theme): Record => { - const base = theme.colors.syntax.base; - const tokens = theme.colors.syntax.tokens ?? {}; - const status = theme.colors.status; - - return { - '--md-syntax-foreground': base.foreground, - '--md-syntax-comment': base.comment, - '--md-syntax-string': base.string, - '--md-syntax-number': base.number, - '--md-syntax-keyword': base.keyword, - '--md-syntax-operator': base.operator, - '--md-syntax-function': base.function, - '--md-syntax-type': base.type, - '--md-syntax-variable': base.variable, - '--md-syntax-property': tokens.variableProperty ?? base.variable, - '--md-syntax-inserted': status.success, - '--md-syntax-deleted': status.error, - }; -}; diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 466942f3..d28450ea 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -59,6 +59,7 @@ Use this doc when you ask an agent to change tool/header/description behavior. - Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`. - The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card. - `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render. +- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`. - Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering. - Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`). @@ -93,7 +94,7 @@ Why: only navigation tools use the compact static path; all other tools need obs ## Quick map of files in this folder - Text: `AssistantTextPart.tsx`, `UserTextPart.tsx` -- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx` +- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx` - Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx` - Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx` - Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx` diff --git a/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx b/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx new file mode 100644 index 00000000..6e6e91e8 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +/** + * Plain-text patch rendering used when the rich `@pierre/diffs` preview is + * unavailable: non-diff render modes, preview errors, and while the lazily + * loaded diff preview chunk is still downloading. Lives in its own module so + * `ToolPart` can render it without importing the @pierre/diffs stack. + */ +export const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => ( +
+        {diff}
+    
+); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 2969dd85..c5a2e4a3 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { useMobileAppActions } from '@/apps/mobileAppContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; -import { PatchDiff } from '@pierre/diffs/react'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { MessageFilesDisplay } from '../../FileAttachment'; @@ -10,7 +9,6 @@ import { getToolMetadata } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2'; import { toolDisplayStyles } from '@/lib/typography'; import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; -import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context'; @@ -24,8 +22,8 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { copyTextToClipboard } from '@/lib/clipboard'; import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; import type { ToolPopupContent } from '../types'; -import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; -import { getDefaultTheme } from '@/lib/theme/themes'; +import { PlainDiffFallback } from './PlainDiffFallback'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { formatEditOutput, @@ -385,40 +383,6 @@ const getToolDiagnosticSection = ( }; }; -const usePierreThemeConfig = () => { - const themeSystem = useOptionalThemeSystem(); - const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []); - const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []); - - const availableThemes = React.useMemo( - () => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme], - [fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes], - ); - const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id; - const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id; - - const lightTheme = React.useMemo( - () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme, - [availableThemes, fallbackLightTheme, lightThemeId], - ); - const darkTheme = React.useMemo( - () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme, - [availableThemes, darkThemeId, fallbackDarkTheme], - ); - - React.useEffect(() => { - ensurePierreThemeRegistered(lightTheme); - ensurePierreThemeRegistered(darkTheme); - }, [darkTheme, lightTheme]); - - const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light'; - - return { - pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id }, - pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const), - }; -}; - // Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..." const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => { const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s); @@ -1138,30 +1102,6 @@ const TaskToolSummary: React.FC<{ ); }; -interface DiffPreviewProps { - diff: string; - pierreTheme: { light: string; dark: string }; - pierreThemeType: 'light' | 'dark'; - diffViewMode: DiffViewMode; -} - -const TOOL_DIFF_UNSAFE_CSS = ` - [data-diff-header], - [data-diff] { - [data-separator] { - height: 24px !important; - } - } -`; - -const TOOL_DIFF_METRICS = { - hunkLineCount: 50, - lineHeight: 24, - diffHeaderHeight: 44, - hunkSeparatorHeight: 24, - spacing: 0, -}; - const TOOL_COLLAPSED_CUSTOM_STYLE: React.CSSProperties = { ...toolDisplayStyles.getCollapsedStyles(), padding: 0, @@ -1260,85 +1200,18 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s ); }; -const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => ( -
-        {diff}
-    
+// The rich diff preview is the only tool-card piece that needs the +// @pierre/diffs + Shiki stack; lazy-loading it keeps that stack out of the +// eager chat graph. While the chunk loads, the plain-text patch renders as the +// Suspense fallback, mirroring the preview's own error fallback. +const LazyToolPartDiffPreview = lazyWithChunkRecovery(() => import('./ToolPartDiffPreview')); + +const DiffPreview: React.FC<{ diff: string; diffViewMode: DiffViewMode }> = ({ diff, diffViewMode }) => ( + }> + + ); -class DiffPreviewErrorBoundary extends React.Component<{ - resetKey: string; - fallback: React.ReactNode; - children: React.ReactNode; -}, { hasError: boolean }> { - state = { hasError: false }; - - static getDerivedStateFromError(): { hasError: boolean } { - return { hasError: true }; - } - - componentDidUpdate(prevProps: { resetKey: string }) { - if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { - this.setState({ hasError: false }); - } - } - - componentDidCatch(error: Error) { - if (process.env.NODE_ENV === 'development') { - console.warn('Tool diff preview failed; rendering raw patch instead.', error); - } - } - - render() { - if (this.state.hasError) { - return this.props.fallback; - } - return this.props.children; - } -} - -const DiffPreview: React.FC = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => { - const options = React.useMemo( - () => ({ - diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const, - diffIndicators: 'none' as const, - hunkSeparators: 'line-info-basic' as const, - lineDiffType: 'none' as const, - disableFileHeader: true, - maxLineDiffLength: 1000, - expansionLineCount: 20, - overflow: 'wrap' as const, - theme: pierreTheme, - themeType: pierreThemeType, - unsafeCSS: TOOL_DIFF_UNSAFE_CSS, - }), - [diffViewMode, pierreTheme, pierreThemeType] - ); - - const fallback = ; - - return ( -
- - - -
- ); -}); - -DiffPreview.displayName = 'DiffPreview'; - interface ToolExpandedContentProps { part: ToolPartType; state: ToolStateUnion; @@ -1357,7 +1230,6 @@ const ToolExpandedContent: React.FC = React.memo(({ const { t } = useI18n(); const runtime = React.useContext(RuntimeAPIContext); const mobileActions = useMobileAppActions(); - const { pierreTheme, pierreThemeType } = usePierreThemeConfig(); const [diffViewMode, setDiffViewMode] = React.useState('unified'); const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; @@ -1633,8 +1505,6 @@ const ToolExpandedContent: React.FC = React.memo(({ {entry.renderMode === 'diff' ? ( ) : ( @@ -1753,8 +1623,6 @@ const ToolExpandedContent: React.FC = React.memo(({ ) : isWriteLikeTool && writeLikeInputPatch ? ( ) : ( diff --git a/packages/ui/src/components/chat/message/parts/ToolPartDiffPreview.tsx b/packages/ui/src/components/chat/message/parts/ToolPartDiffPreview.tsx new file mode 100644 index 00000000..7cfd6592 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/ToolPartDiffPreview.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { PatchDiff } from '@pierre/diffs/react'; + +import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; +import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; +import { getDefaultTheme } from '@/lib/theme/themes'; +import type { DiffViewMode } from '../DiffViewToggle'; +import { PlainDiffFallback } from './PlainDiffFallback'; + +// Loaded lazily from ToolPart: this is the only part of the tool card that +// needs @pierre/diffs' rendering stack (Shiki core + regex engines), so the +// eager chat graph stays free of it and the chunk downloads on the first +// rendered tool diff. + +const TOOL_DIFF_UNSAFE_CSS = ` + [data-diff-header], + [data-diff] { + [data-separator] { + height: 24px !important; + } + } +`; + +const TOOL_DIFF_METRICS = { + hunkLineCount: 50, + lineHeight: 24, + diffHeaderHeight: 44, + hunkSeparatorHeight: 24, + spacing: 0, +}; + +const usePierreThemeConfig = () => { + const themeSystem = useOptionalThemeSystem(); + const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []); + const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []); + + const availableThemes = React.useMemo( + () => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme], + [fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes], + ); + const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id; + const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id; + + const lightTheme = React.useMemo( + () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme, + [availableThemes, fallbackLightTheme, lightThemeId], + ); + const darkTheme = React.useMemo( + () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme, + [availableThemes, darkThemeId, fallbackDarkTheme], + ); + + // Registration is synchronous module state inside @pierre/diffs; rendering + // a PatchDiff with these theme ids requires it to have happened first, so + // register during render rather than in an effect. + ensurePierreThemeRegistered(lightTheme); + ensurePierreThemeRegistered(darkTheme); + + const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light'; + + return { + pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id }, + pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const), + }; +}; + +class DiffPreviewErrorBoundary extends React.Component<{ + resetKey: string; + fallback: React.ReactNode; + children: React.ReactNode; +}, { hasError: boolean }> { + state = { hasError: false }; + + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; + } + + componentDidUpdate(prevProps: { resetKey: string }) { + if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { + this.setState({ hasError: false }); + } + } + + componentDidCatch(error: Error) { + if (process.env.NODE_ENV === 'development') { + console.warn('Tool diff preview failed; rendering raw patch instead.', error); + } + } + + render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} + +export interface ToolPartDiffPreviewProps { + diff: string; + diffViewMode: DiffViewMode; +} + +const ToolPartDiffPreview: React.FC = React.memo(({ diff, diffViewMode }) => { + const { pierreTheme, pierreThemeType } = usePierreThemeConfig(); + const options = React.useMemo( + () => ({ + diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const, + diffIndicators: 'none' as const, + hunkSeparators: 'line-info-basic' as const, + lineDiffType: 'none' as const, + disableFileHeader: true, + maxLineDiffLength: 1000, + expansionLineCount: 20, + overflow: 'wrap' as const, + theme: pierreTheme, + themeType: pierreThemeType, + unsafeCSS: TOOL_DIFF_UNSAFE_CSS, + }), + [diffViewMode, pierreTheme, pierreThemeType] + ); + + const fallback = ; + + return ( +
+ + + +
+ ); +}); + +ToolPartDiffPreview.displayName = 'ToolPartDiffPreview'; + +export default ToolPartDiffPreview; diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx index 11e7763f..5ef116d7 100644 --- a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; +import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars'; import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; // ── Threshold: files smaller than this render without virtualization ── diff --git a/packages/ui/src/components/code/WorkerHighlightedCode.tsx b/packages/ui/src/components/code/WorkerHighlightedCode.tsx index 441972d8..60c5e3dd 100644 --- a/packages/ui/src/components/code/WorkerHighlightedCode.tsx +++ b/packages/ui/src/components/code/WorkerHighlightedCode.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; +import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars'; import { highlightCodeInWorker } from '@/components/chat/markdown/markdown-worker'; // Shared static code highlighter backed by the markdown Shiki Web Worker. diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index d0b1f9c1..a8d5b159 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -4,13 +4,18 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { DiffViewIcon } from '@/components/icons/DiffIcon'; import { Button } from '@/components/ui/button'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; -import { DiffView } from '@/components/views/DiffView'; -import { FilesView } from '@/components/views/FilesView'; -import { GitView } from '@/components/views/GitView'; import { PullRequestView } from '@/components/views/PullRequestView'; import { TerminalView } from '@/components/views/TerminalView'; -import { WalkthroughView } from '@/components/views/walkthrough/WalkthroughView'; -import { PlanView } from '@/components/views/PlanView'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; + +// Heavy views stay on-demand (same as MainLayout): importing DiffView/FilesView +// or the walkthrough statically pulls the CodeMirror and @pierre/diffs stacks +// into the eager startup graph even when no such tab is open. +const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/walkthrough/WalkthroughView').then((m) => ({ default: m.WalkthroughView }))); +const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView }))); +const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView }))); +const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView }))); +const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView }))); import { ProjectContextPanel } from './RightSidebarTabs'; import { SidebarFilesTree } from './SidebarFilesTree'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -2699,13 +2704,13 @@ export const ContextPanel: React.FC = () => { const activeNonChatContent = activeTab?.mode === 'context' ? : activeTab?.mode === 'git' - ? + ? : activeTab?.mode === 'pr' ? : activeTab?.mode === 'notes' ? : activeTab?.mode === 'plan' - ? + ? : activeTab?.mode === 'preview' ? openContextPreview(effectiveDirectory, url)} /> : ( @@ -2917,7 +2922,7 @@ export const ContextPanel: React.FC = () => {
{hasOpenEditorFile ? ( - + ) : (
@@ -2983,16 +2988,18 @@ export const ContextPanel: React.FC = () => { activeTab?.id !== tab.id && 'hidden' )} > - + + +
))} {hasTerminalTab ? ( @@ -3002,7 +3009,9 @@ export const ContextPanel: React.FC = () => { ) : null} {hasWalkthroughTab ? (
- + + +
) : null} {activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null} diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index b089d4e9..8755aae4 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -29,14 +29,16 @@ import { cn } from '@/lib/utils'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { ChatView } from '@/components/views/ChatView'; -import { DiffView } from '@/components/views/DiffView'; -import { FilesView } from '@/components/views/FilesView'; -import { GitView } from '@/components/views/GitView'; -import { PlanView } from '@/components/views/PlanView'; // Keep TerminalView eager: the bottom dock reserves its height immediately, so // suspending here leaves a large blank panel on slower machines. -// Other heavy views stay on-demand to reduce initial bundle parse time. +// Other heavy views stay on-demand to reduce initial bundle parse time: +// DiffView/FilesView pull the CodeMirror and @pierre/diffs stacks into the +// startup graph when imported statically. +const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView }))); +const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView }))); +const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView }))); +const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView }))); const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView }))); const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); @@ -48,6 +50,17 @@ export const MainLayout: React.FC = () => { const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + // Mount the windowed settings dialog only after its first open: rendering + // the lazy component (even closed) makes React fetch the SettingsView + // chunk graph (CodeMirror editor, vim mode, theme tooling) on startup. + // Once opened it stays mounted so the close animation and state behave as + // before. + const [settingsWindowMounted, setSettingsWindowMounted] = React.useState(false); + React.useEffect(() => { + if (isSettingsDialogOpen) { + setSettingsWindowMounted(true); + } + }, [isSettingsDialogOpen]); const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen); const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen); const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt); @@ -464,12 +477,14 @@ export const MainLayout: React.FC = () => {
{/* Desktop settings: windowed dialog with blur */} - - - + {settingsWindowMounted ? ( + + + + ) : null} )} diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index cd86497f..82657445 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web'; +import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web'; import { cn } from '@/lib/utils'; import type { TerminalTheme } from '@/lib/terminalTheme'; @@ -15,8 +15,33 @@ import { } from '@/lib/terminalTouchSelection'; import type { TerminalChunk } from '@/stores/useTerminalStore'; -let ghosttyPromise: Promise | null = null; -const loadGhostty = (): Promise => ghosttyPromise ??= Ghostty.load(); +// ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView +// stays eagerly importable for the bottom dock without pulling the emulator +// into the startup graph before a terminal is actually mounted. +type GhosttyModule = typeof import('ghostty-web'); +type GhosttyRuntime = { module: GhosttyModule; ghostty: Ghostty }; +let ghosttyRuntimePromise: Promise | null = null; +const loadGhostty = (): Promise => + ghosttyRuntimePromise ??= import('ghostty-web').then(async (module) => ({ + module, + ghostty: await module.Ghostty.load(), + })); + +// The web entry defers its ~2 MB Nerd Font download until a terminal actually +// mounts (see the `__openchamberEnsureNerdFonts` hook in index.html). Wait for +// it with a short bound so a cached font is in place before the glyph atlas is +// built, while a cold CDN fetch never blocks the terminal from opening; the +// runtimes without the hook (VS Code, mobile) resolve immediately. +const NERD_FONT_WAIT_MS = 2000; +const ensureNerdFonts = (): Promise => { + if (typeof window === 'undefined') return Promise.resolve(); + const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise }).__openchamberEnsureNerdFonts; + if (typeof loader !== 'function') return Promise.resolve(); + return Promise.race([ + Promise.resolve(loader()).catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)), + ]).then(() => undefined); +}; type TerminalSize = { cols: number; rows: number }; @@ -211,13 +236,13 @@ const TerminalViewport = React.forwardRef(({ window.addEventListener('focus', handleWindowFocus); window.addEventListener('blur', handleWindowBlur); - loadGhostty().then((ghostty) => { + Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => { if (disposed) return; - terminal = new GhosttyTerminal({ + terminal = new module.Terminal({ ...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false), ...(provisionalSizeRef.current ?? {}), }); - const fitAddon = new FitAddon(); + const fitAddon = new module.FitAddon(); terminal.loadAddon(fitAddon); terminal.open(container); terminalRef.current = terminal; diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 85cf5093..d54861be 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -41,7 +41,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntim import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata'; const EMPTY_PINNED_SESSION_IDS = new Set(); -import { getSettingsNavIcon } from '@/components/views/SettingsView'; +import { getSettingsNavIcon } from '@/lib/settings/metadata'; import { Icon } from "@/components/icon/Icon"; import { McpIcon } from '@/components/icons/McpIcon'; import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch'; diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 74505195..cf3c6237 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -46,11 +46,11 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRunti import { isWindowsArm64 as isWindowsArm64Platform } from '@/lib/platform'; import { useI18n } from '@/lib/i18n'; import { Icon } from "@/components/icon/Icon"; -import type { IconName } from "@/components/icon/icons"; import { McpIcon } from '@/components/icons/McpIcon'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { SETTINGS_PAGE_METADATA, + getSettingsNavIcon, getSettingsPageMeta, resolveSettingsSlug, type SettingsPageSlug, @@ -113,7 +113,6 @@ const pageOrder: SettingsPageSlug[] = [ const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const; -const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const; const ADD_PROVIDER_SETTINGS_ID = '__add_provider__'; function buildRuntimeContext(isDesktop: boolean, isMobile: boolean): SettingsRuntimeContext { @@ -171,65 +170,6 @@ function getCurrentHistoryState(): Record { return window.history.state; } -// eslint-disable-next-line react-refresh/only-export-components -export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null { - switch (slug) { - case 'general': - return 'settings-3'; - case 'projects': - return 'folders'; - case 'remote-instances': - return 'computer'; - case 'appearance': - return 'palette'; - case 'chat': - return 'chat-ai-3'; - case 'magic-prompts': - return 'ai-generate-2'; - case 'snippets': - return SNIPPETS_SETTINGS_ICON.icon; - case 'notifications': - return 'notification-3'; - case 'shortcuts': - return 'command'; - case 'sessions': - return 'chat-history'; - - case 'providers': - return 'cloud'; - case 'agents': - return 'ai-agent'; - case 'behavior': - return 'brain'; - case 'commands': - return 'slash-commands-2'; - case 'mcp': - return null; - case 'plugins': - return 'plug-2'; - - case 'skills.installed': - return 'book-open'; - case 'skills.catalog': - return 'book'; - - case 'git': - return 'git-branch'; - - case 'usage': - return 'bar-chart-2'; - case 'voice': - return 'mic'; - case 'tunnel': - return 'home-office'; - case 'about': - return 'information'; - case 'home': - return null; - default: - return 'robot-2'; - } -} export const SettingsView: React.FC = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => { const { t } = useI18n(); diff --git a/packages/ui/src/contexts/DiffWorkerProvider.tsx b/packages/ui/src/contexts/DiffWorkerProvider.tsx index 2dafedc9..b95ef433 100644 --- a/packages/ui/src/contexts/DiffWorkerProvider.tsx +++ b/packages/ui/src/contexts/DiffWorkerProvider.tsx @@ -1,12 +1,16 @@ -import React, { useMemo, useEffect } from 'react'; +import React, { useEffect, useSyncExternalStore } from 'react'; import type { SupportedLanguages } from '@pierre/diffs'; -import { WorkerPoolManager } from '@pierre/diffs/worker'; +import type { WorkerPoolManager } from '@pierre/diffs/worker'; import { useOptionalThemeSystem } from './useThemeSystem'; -import { workerFactory } from '@/lib/diff/workerFactory'; -import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; +import type { Theme } from '@/types/theme'; // NOTE: keep provider lightweight; avoid main-thread diff parsing here. +// This module must not statically import `@pierre/diffs` runtime code: +// `@pierre/diffs/worker` pulls the Shiki highlighter (core + oniguruma engine +// + grammar registry) into the eager startup graph and `initialize()` spawns +// workers plus a main-thread shared highlighter before any diff is visible. +// Everything heavy loads on demand and is only warmed after startup idle. // Preload common languages for faster initial diff rendering const PRELOAD_LANGS: SupportedLanguages[] = [ @@ -38,68 +42,90 @@ const WORKER_POOL_CONFIG: Record Worker; + ensurePierreThemeRegistered: (theme: Theme) => void; +}; -const createWorkerPool = (style: WorkerPoolStyle) => { - const config = WORKER_POOL_CONFIG[style]; - const pool = new WorkerPoolManager( - { - workerFactory, - poolSize: config.poolSize, - totalASTLRUCacheSize: config.totalASTLRUCacheSize, - }, - { - theme: { - light: 'pierre-light', - dark: 'pierre-dark', +let poolModulesPromise: Promise | null = null; + +const loadPoolModules = (): Promise => { + poolModulesPromise ??= Promise.all([ + import('@pierre/diffs/worker'), + import('@/lib/diff/workerFactory'), + import('@/lib/shiki/appThemeRegistry'), + ]).then(([workerModule, factoryModule, themeRegistryModule]) => ({ + WorkerPoolManager: workerModule.WorkerPoolManager, + workerFactory: factoryModule.workerFactory, + ensurePierreThemeRegistered: themeRegistryModule.ensurePierreThemeRegistered, + })); + return poolModulesPromise; +}; + +const pools: Partial> = {}; +const poolsRequested = new Set(); +const poolListeners = new Set<() => void>(); + +let currentRenderTheme: { light: string; dark: string } = { + light: 'pierre-light', + dark: 'pierre-dark', +}; + +const notifyPoolListeners = () => { + for (const listener of poolListeners) listener(); +}; + +const applyRenderOptions = (style: WorkerPoolStyle, pool: WorkerPoolManager) => { + void pool.setRenderOptions({ + theme: currentRenderTheme, + lineDiffType: WORKER_POOL_CONFIG[style].lineDiffType, + }); +}; + +const ensurePool = (style: WorkerPoolStyle): void => { + if (typeof window === 'undefined' || poolsRequested.has(style)) return; + poolsRequested.add(style); + void loadPoolModules().then((modules) => { + if (pools[style]) return; + const config = WORKER_POOL_CONFIG[style]; + const pool = new modules.WorkerPoolManager( + { + workerFactory: modules.workerFactory, + poolSize: config.poolSize, + totalASTLRUCacheSize: config.totalASTLRUCacheSize, }, - langs: PRELOAD_LANGS, - lineDiffType: config.lineDiffType, - preferredHighlighter: 'shiki-wasm', - } - ); - void pool.initialize(); - return pool; + { + theme: { + light: 'pierre-light', + dark: 'pierre-dark', + }, + langs: PRELOAD_LANGS, + lineDiffType: config.lineDiffType, + preferredHighlighter: 'shiki-wasm', + } + ); + void pool.initialize(); + pools[style] = pool; + applyRenderOptions(style, pool); + notifyPoolListeners(); + }); }; -const getWorkerPool = (style: WorkerPoolStyle): WorkerPoolManager | undefined => { - if (typeof window === 'undefined') { - return undefined; - } - - if (style === 'split') { - splitWorkerPool ??= createWorkerPool('split'); - return splitWorkerPool; - } - - unifiedWorkerPool ??= createWorkerPool('unified'); - return unifiedWorkerPool; +const subscribeToPools = (listener: () => void): (() => void) => { + poolListeners.add(listener); + return () => poolListeners.delete(listener); }; -const WorkerPoolWarmup: React.FC<{ - children: React.ReactNode; - renderTheme: { light: string; dark: string }; -}> = ({ children, renderTheme }) => { - const unifiedPool = useWorkerPool('unified'); - const splitPool = useWorkerPool('split'); - - useEffect(() => { - if (unifiedPool) { - void unifiedPool.setRenderOptions({ - theme: renderTheme, - lineDiffType: WORKER_POOL_CONFIG.unified.lineDiffType, - }); - } - if (splitPool) { - void splitPool.setRenderOptions({ - theme: renderTheme, - lineDiffType: WORKER_POOL_CONFIG.split.lineDiffType, - }); - } - }, [renderTheme, splitPool, unifiedPool]); - - return <>{children}; +const setRenderTheme = (renderTheme: { light: string; dark: string }) => { + if (currentRenderTheme.light === renderTheme.light && currentRenderTheme.dark === renderTheme.dark) { + return; + } + currentRenderTheme = renderTheme; + for (const style of Object.keys(pools) as WorkerPoolStyle[]) { + const pool = pools[style]; + if (pool) applyRenderOptions(style, pool); + } }; export const DiffWorkerProvider: React.FC = ({ children }) => { @@ -118,25 +144,54 @@ export const DiffWorkerProvider: React.FC = ({ children themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDark; - ensurePierreThemeRegistered(lightTheme); - ensurePierreThemeRegistered(darkTheme); + // Register the active app themes with @pierre/diffs and forward them to any + // live pools. Registration goes through the deferred module load so the + // theme registry (and its @pierre/diffs import) stays out of the eager + // startup graph; each diff surface also registers the themes it renders + // with, so ordering is preserved even before this resolves. + useEffect(() => { + let cancelled = false; + void loadPoolModules().then((modules) => { + if (cancelled) return; + modules.ensurePierreThemeRegistered(lightTheme); + modules.ensurePierreThemeRegistered(darkTheme); + setRenderTheme({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id }); + }); + return () => { + cancelled = true; + }; + }, [darkTheme, lightTheme]); - const renderTheme = useMemo( - () => ({ - light: lightTheme.metadata.id, - dark: darkTheme.metadata.id, - }), - [darkTheme.metadata.id, lightTheme.metadata.id], - ); + // Warm the worker pools once startup work has settled so the first diff a + // user opens does not pay worker spawn + highlighter init. Idle-deferred: + // warming competed with initial load (3 workers, shiki grammars, oniguruma + // wasm) when it ran during mount. + useEffect(() => { + if (typeof window === 'undefined') return; + const warm = () => { + ensurePool('unified'); + ensurePool('split'); + }; + if (typeof window.requestIdleCallback === 'function') { + const handle = window.requestIdleCallback(warm, { timeout: 5000 }); + return () => window.cancelIdleCallback(handle); + } + const timeout = window.setTimeout(warm, 2000); + return () => window.clearTimeout(timeout); + }, []); - return ( - - {children} - - ); + return <>{children}; }; // eslint-disable-next-line react-refresh/only-export-components export const useWorkerPool = (style: WorkerPoolStyle = 'unified'): WorkerPoolManager | undefined => { - return useMemo(() => getWorkerPool(style), [style]); + const pool = useSyncExternalStore( + subscribeToPools, + () => pools[style], + () => undefined, + ); + useEffect(() => { + ensurePool(style); + }, [style]); + return pool; }; diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index 9630ec1d..f8d96d72 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -1,4 +1,5 @@ import type { SidebarSection } from '@/constants/sidebar'; +import type { IconName } from '@/components/icon/icons'; export type SettingsPageSlug = | 'home' @@ -237,3 +238,65 @@ export function resolveSettingsSlug(value: string | null | undefined): SettingsP return 'home'; } + +// Lives here (not in SettingsView) so light consumers such as the command +// palette can render settings entries without statically importing the whole +// settings surface into the eager startup graph. +export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null { + switch (slug) { + case 'general': + return 'settings-3'; + case 'projects': + return 'folders'; + case 'remote-instances': + return 'computer'; + case 'appearance': + return 'palette'; + case 'chat': + return 'chat-ai-3'; + case 'magic-prompts': + return 'ai-generate-2'; + case 'snippets': + return 'chat-thread'; + case 'notifications': + return 'notification-3'; + case 'shortcuts': + return 'command'; + case 'sessions': + return 'chat-history'; + + case 'providers': + return 'cloud'; + case 'agents': + return 'ai-agent'; + case 'behavior': + return 'brain'; + case 'commands': + return 'slash-commands-2'; + case 'mcp': + return null; + case 'plugins': + return 'plug-2'; + + case 'skills.installed': + return 'book-open'; + case 'skills.catalog': + return 'book'; + + case 'git': + return 'git-branch'; + + case 'usage': + return 'bar-chart-2'; + case 'voice': + return 'mic'; + case 'tunnel': + return 'home-office'; + case 'about': + return 'information'; + case 'home': + return null; + default: + return 'robot-2'; + } +} diff --git a/packages/web/index.html b/packages/web/index.html index 2434decd..45496b05 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -15,12 +15,6 @@ - - - - - + diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 272f775a..60877c12 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -157,6 +157,28 @@ export default defineConfig({ const segments = match.split('/'); const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0]; + // Shiki grammars/themes and CodeMirror legacy modes are dynamically + // imported one at a time by their registries. Forcing them into a + // single vendor chunk makes the first language request download every + // grammar (7.4 MB raw for @shikijs/langs). Let Rollup split them per + // dynamically imported module so only used languages are fetched — + // the worker build already behaves this way. + if ( + packageName === '@shikijs/langs' || + packageName === '@shikijs/themes' || + packageName === '@codemirror/legacy-modes' + ) { + return undefined; + } + + // Split @pierre/diffs by usage as well: the eager tool renderer needs + // only its pure patch parser, while the Shiki-importing render stack + // must stay loadable on demand. One merged vendor chunk would make + // the parser import download the whole stack eagerly. + if (packageName === '@pierre/diffs') { + return undefined; + } + if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react'; if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand'; diff --git a/vite.config.ts b/vite.config.ts index ae9721f4..bcaf5d27 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,6 +55,28 @@ export default defineConfig({ const segments = match.split('/') const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0] + // Shiki grammars/themes and CodeMirror legacy modes are dynamically + // imported one at a time by their registries. Forcing them into a + // single vendor chunk makes the first language request download every + // grammar (7.4 MB raw for @shikijs/langs). Let Rollup split them per + // dynamically imported module so only used languages are fetched — + // the worker build already behaves this way. + if ( + packageName === '@shikijs/langs' || + packageName === '@shikijs/themes' || + packageName === '@codemirror/legacy-modes' + ) { + return undefined + } + + // Split @pierre/diffs by usage as well: the eager tool renderer needs + // only its pure patch parser, while the Shiki-importing render stack + // must stay loadable on demand. One merged vendor chunk would make + // the parser import download the whole stack eagerly. + if (packageName === '@pierre/diffs') { + return undefined + } + if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react' if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand' if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk'