perf: cut cold-start download 58% and startup heap 22% via measured chunk-graph fixes (#2742)
* fix(ui): update session-switch-resync test to current handleEvent/setSessionTodos signatures
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* 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 <makeittech@users.noreply.github.com>
* 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 <makeittech@users.noreply.github.com>
* 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 <makeittech@users.noreply.github.com>
* 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 <makeittech@users.noreply.github.com>
---------
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
parent
da3d467f82
commit
bc380e6e1b
@@ -32,7 +32,7 @@ import {
|
|||||||
} from '@/lib/chatDraftPersistence';
|
} from '@/lib/chatDraftPersistence';
|
||||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||||
import ToolOutputDialog from './message/ToolOutputDialog';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
import type { ToolPopupContent } from './message/types';
|
import type { ToolPopupContent } from './message/types';
|
||||||
import { QueuedMessageChips } from './QueuedMessageChips';
|
import { QueuedMessageChips } from './QueuedMessageChips';
|
||||||
import { AutoReviewBanner } from './AutoReviewBanner';
|
import { AutoReviewBanner } from './AutoReviewBanner';
|
||||||
@@ -142,6 +142,10 @@ import { RevertedMessageDock } from './composer/ui/RevertedMessageDock';
|
|||||||
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
||||||
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
|
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;
|
const MAX_VISIBLE_COMPOSER_LINES = 8;
|
||||||
/**
|
/**
|
||||||
* Mobile grows the composer with content instead of offering a fullscreen
|
* Mobile grows the composer with content instead of offering a fullscreen
|
||||||
@@ -383,6 +387,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
title: '',
|
title: '',
|
||||||
content: '',
|
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({
|
const attachmentCompatibilityRef = React.useRef({
|
||||||
modelKey: `${currentProviderId ?? ''}/${currentModelId ?? ''}`,
|
modelKey: `${currentProviderId ?? ''}/${currentModelId ?? ''}`,
|
||||||
modalitySignature: currentModelMetadata?.modalities?.input?.slice().sort().join(',') ?? null,
|
modalitySignature: currentModelMetadata?.modalities?.input?.slice().sort().join(',') ?? null,
|
||||||
@@ -2786,11 +2799,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
submitting={reviewFlowSubmitting}
|
submitting={reviewFlowSubmitting}
|
||||||
onConfirm={handleStartReviewFlow}
|
onConfirm={handleStartReviewFlow}
|
||||||
/>
|
/>
|
||||||
<ToolOutputDialog
|
{attachmentPreviewMounted ? (
|
||||||
popup={attachmentPreview}
|
<React.Suspense fallback={null}>
|
||||||
onOpenChange={handleAttachmentPreviewOpenChange}
|
<ToolOutputDialog
|
||||||
isMobile={isMobile}
|
popup={attachmentPreview}
|
||||||
/>
|
onOpenChange={handleAttachmentPreviewOpenChange}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Single always-mounted picker input. It must NOT live inside
|
{/* Single always-mounted picker input. It must NOT live inside
|
||||||
ComposerAttachmentControls: that component mounts once per composer
|
ComposerAttachmentControls: that component mounts once per composer
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
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 { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||||
import { parseDiffToUnified } from './message/toolRenderers';
|
import { parseDiffToUnified } from './message/toolRenderers';
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
|||||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
||||||
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
|
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||||
|
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||||
import {
|
import {
|
||||||
attachMarkdownInteractions,
|
attachMarkdownInteractions,
|
||||||
applyMarkdownCodeBlockWrapState,
|
applyMarkdownCodeBlockWrapState,
|
||||||
|
|||||||
@@ -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<string, string> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { registerCustomTheme, type ThemeRegistrationResolved } from '@pierre/diffs';
|
import { registerCustomTheme, type ThemeRegistrationResolved } from '@pierre/diffs';
|
||||||
import type { Theme } from '@/types/theme';
|
|
||||||
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
||||||
|
|
||||||
// The static Shiki theme name. Its definition (token colors referencing
|
// 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),
|
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<string, string> => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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`.
|
- 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.
|
- 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.
|
- `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.
|
- 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`).
|
- 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
|
## Quick map of files in this folder
|
||||||
|
|
||||||
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
- 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`
|
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||||
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx`
|
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.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 }) => (
|
||||||
|
<pre
|
||||||
|
className="m-0 overflow-auto whitespace-pre-wrap break-words rounded-lg p-2 typography-code"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--syntax-base-background)',
|
||||||
|
color: 'var(--syntax-base-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diff}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||||
import { PatchDiff } from '@pierre/diffs/react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { MessageFilesDisplay } from '../../FileAttachment';
|
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 type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2';
|
||||||
import { toolDisplayStyles } from '@/lib/typography';
|
import { toolDisplayStyles } from '@/lib/typography';
|
||||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
|
import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
|
||||||
@@ -24,8 +22,8 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||||
import type { ToolPopupContent } from '../types';
|
import type { ToolPopupContent } from '../types';
|
||||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
import { PlainDiffFallback } from './PlainDiffFallback';
|
||||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatEditOutput,
|
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..."
|
// 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 parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => {
|
||||||
const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s);
|
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 = {
|
const TOOL_COLLAPSED_CUSTOM_STYLE: React.CSSProperties = {
|
||||||
...toolDisplayStyles.getCollapsedStyles(),
|
...toolDisplayStyles.getCollapsedStyles(),
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -1260,85 +1200,18 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => (
|
// The rich diff preview is the only tool-card piece that needs the
|
||||||
<pre
|
// @pierre/diffs + Shiki stack; lazy-loading it keeps that stack out of the
|
||||||
className="m-0 overflow-auto whitespace-pre-wrap break-words rounded-lg p-2 typography-code"
|
// eager chat graph. While the chunk loads, the plain-text patch renders as the
|
||||||
style={{
|
// Suspense fallback, mirroring the preview's own error fallback.
|
||||||
backgroundColor: 'var(--syntax-base-background)',
|
const LazyToolPartDiffPreview = lazyWithChunkRecovery(() => import('./ToolPartDiffPreview'));
|
||||||
color: 'var(--syntax-base-foreground)',
|
|
||||||
}}
|
const DiffPreview: React.FC<{ diff: string; diffViewMode: DiffViewMode }> = ({ diff, diffViewMode }) => (
|
||||||
>
|
<React.Suspense fallback={<PlainDiffFallback diff={diff} />}>
|
||||||
{diff}
|
<LazyToolPartDiffPreview diff={diff} diffViewMode={diffViewMode} />
|
||||||
</pre>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
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<DiffPreviewProps> = 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 = <PlainDiffFallback diff={diff} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="typography-code px-1 pb-1 pt-0">
|
|
||||||
<DiffPreviewErrorBoundary resetKey={diff} fallback={fallback}>
|
|
||||||
<PatchDiff
|
|
||||||
patch={diff}
|
|
||||||
metrics={TOOL_DIFF_METRICS}
|
|
||||||
options={options}
|
|
||||||
className="block w-full"
|
|
||||||
/>
|
|
||||||
</DiffPreviewErrorBoundary>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
DiffPreview.displayName = 'DiffPreview';
|
|
||||||
|
|
||||||
interface ToolExpandedContentProps {
|
interface ToolExpandedContentProps {
|
||||||
part: ToolPartType;
|
part: ToolPartType;
|
||||||
state: ToolStateUnion;
|
state: ToolStateUnion;
|
||||||
@@ -1357,7 +1230,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const runtime = React.useContext(RuntimeAPIContext);
|
const runtime = React.useContext(RuntimeAPIContext);
|
||||||
const mobileActions = useMobileAppActions();
|
const mobileActions = useMobileAppActions();
|
||||||
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
|
|
||||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
|
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
|
||||||
const stateWithData = state as ToolStateWithMetadata;
|
const stateWithData = state as ToolStateWithMetadata;
|
||||||
const metadata = stateWithData.metadata;
|
const metadata = stateWithData.metadata;
|
||||||
@@ -1633,8 +1505,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
{entry.renderMode === 'diff' ? (
|
{entry.renderMode === 'diff' ? (
|
||||||
<DiffPreview
|
<DiffPreview
|
||||||
diff={entry.patch}
|
diff={entry.patch}
|
||||||
pierreTheme={pierreTheme}
|
|
||||||
pierreThemeType={pierreThemeType}
|
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1753,8 +1623,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
) : isWriteLikeTool && writeLikeInputPatch ? (
|
) : isWriteLikeTool && writeLikeInputPatch ? (
|
||||||
<DiffPreview
|
<DiffPreview
|
||||||
diff={writeLikeInputPatch}
|
diff={writeLikeInputPatch}
|
||||||
pierreTheme={pierreTheme}
|
|
||||||
pierreThemeType={pierreThemeType}
|
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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<ToolPartDiffPreviewProps> = 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 = <PlainDiffFallback diff={diff} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="typography-code px-1 pb-1 pt-0">
|
||||||
|
<DiffPreviewErrorBoundary resetKey={diff} fallback={fallback}>
|
||||||
|
<PatchDiff
|
||||||
|
patch={diff}
|
||||||
|
metrics={TOOL_DIFF_METRICS}
|
||||||
|
options={options}
|
||||||
|
className="block w-full"
|
||||||
|
/>
|
||||||
|
</DiffPreviewErrorBoundary>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ToolPartDiffPreview.displayName = 'ToolPartDiffPreview';
|
||||||
|
|
||||||
|
export default ToolPartDiffPreview;
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
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 { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||||
|
|
||||||
// ── Threshold: files smaller than this render without virtualization ──
|
// ── Threshold: files smaller than this render without virtualization ──
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
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';
|
import { highlightCodeInWorker } from '@/components/chat/markdown/markdown-worker';
|
||||||
|
|
||||||
// Shared static code highlighter backed by the markdown Shiki Web Worker.
|
// Shared static code highlighter backed by the markdown Shiki Web Worker.
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
|||||||
import { DiffViewIcon } from '@/components/icons/DiffIcon';
|
import { DiffViewIcon } from '@/components/icons/DiffIcon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
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 { PullRequestView } from '@/components/views/PullRequestView';
|
||||||
import { TerminalView } from '@/components/views/TerminalView';
|
import { TerminalView } from '@/components/views/TerminalView';
|
||||||
import { WalkthroughView } from '@/components/views/walkthrough/WalkthroughView';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
import { PlanView } from '@/components/views/PlanView';
|
|
||||||
|
// 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 { ProjectContextPanel } from './RightSidebarTabs';
|
||||||
import { SidebarFilesTree } from './SidebarFilesTree';
|
import { SidebarFilesTree } from './SidebarFilesTree';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
@@ -2699,13 +2704,13 @@ export const ContextPanel: React.FC = () => {
|
|||||||
const activeNonChatContent = activeTab?.mode === 'context'
|
const activeNonChatContent = activeTab?.mode === 'context'
|
||||||
? <ContextPanelContent />
|
? <ContextPanelContent />
|
||||||
: activeTab?.mode === 'git'
|
: activeTab?.mode === 'git'
|
||||||
? <GitView isActive={isOpen} />
|
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
|
||||||
: activeTab?.mode === 'pr'
|
: activeTab?.mode === 'pr'
|
||||||
? <PullRequestView />
|
? <PullRequestView />
|
||||||
: activeTab?.mode === 'notes'
|
: activeTab?.mode === 'notes'
|
||||||
? <ProjectContextPanel />
|
? <ProjectContextPanel />
|
||||||
: activeTab?.mode === 'plan'
|
: activeTab?.mode === 'plan'
|
||||||
? <PlanView targetPath={activeTab.targetPath} />
|
? <React.Suspense fallback={null}><PlanView targetPath={activeTab.targetPath} /></React.Suspense>
|
||||||
: activeTab?.mode === 'preview'
|
: activeTab?.mode === 'preview'
|
||||||
? <PreviewPane rawUrl={activeTab.targetPath ?? ''} onNavigate={(url) => openContextPreview(effectiveDirectory, url)} />
|
? <PreviewPane rawUrl={activeTab.targetPath ?? ''} onNavigate={(url) => openContextPreview(effectiveDirectory, url)} />
|
||||||
: (
|
: (
|
||||||
@@ -2917,7 +2922,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
<div className={cn('absolute inset-0 flex', isFileTabActive ? 'flex' : 'hidden')}>
|
<div className={cn('absolute inset-0 flex', isFileTabActive ? 'flex' : 'hidden')}>
|
||||||
<div className="h-full min-w-0 flex-1">
|
<div className="h-full min-w-0 flex-1">
|
||||||
{hasOpenEditorFile ? (
|
{hasOpenEditorFile ? (
|
||||||
<FilesView mode="editor-only" />
|
<React.Suspense fallback={null}><FilesView mode="editor-only" /></React.Suspense>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
<Icon name="file-code" className="h-12 w-12 text-muted-foreground/50" />
|
<Icon name="file-code" className="h-12 w-12 text-muted-foreground/50" />
|
||||||
@@ -2983,16 +2988,18 @@ export const ContextPanel: React.FC = () => {
|
|||||||
activeTab?.id !== tab.id && 'hidden'
|
activeTab?.id !== tab.id && 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DiffView
|
<React.Suspense fallback={null}>
|
||||||
hideStackedFileSidebar
|
<DiffView
|
||||||
stackedDefaultCollapsedAll
|
hideStackedFileSidebar
|
||||||
pinSelectedFileHeaderToTopOnNavigate
|
stackedDefaultCollapsedAll
|
||||||
showOpenInEditorAction
|
pinSelectedFileHeaderToTopOnNavigate
|
||||||
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
|
showOpenInEditorAction
|
||||||
onDiffScopeChange={handleDiffScopeChange}
|
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
|
||||||
targetFilePath={tab.targetPath}
|
onDiffScopeChange={handleDiffScopeChange}
|
||||||
flushContent
|
targetFilePath={tab.targetPath}
|
||||||
/>
|
flushContent
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{hasTerminalTab ? (
|
{hasTerminalTab ? (
|
||||||
@@ -3002,7 +3009,9 @@ export const ContextPanel: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
{hasWalkthroughTab ? (
|
{hasWalkthroughTab ? (
|
||||||
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
|
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
|
||||||
<WalkthroughView directory={effectiveDirectory} />
|
<React.Suspense fallback={null}>
|
||||||
|
<WalkthroughView directory={effectiveDirectory} />
|
||||||
|
</React.Suspense>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null}
|
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null}
|
||||||
|
|||||||
@@ -29,14 +29,16 @@ import { cn } from '@/lib/utils';
|
|||||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
|
||||||
import { ChatView } from '@/components/views/ChatView';
|
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
|
// Keep TerminalView eager: the bottom dock reserves its height immediately, so
|
||||||
// suspending here leaves a large blank panel on slower machines.
|
// 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 DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
|
||||||
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||||
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
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 isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
|
||||||
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
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 isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
|
||||||
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
|
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
|
||||||
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
|
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
|
||||||
@@ -464,12 +477,14 @@ export const MainLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop settings: windowed dialog with blur */}
|
{/* Desktop settings: windowed dialog with blur */}
|
||||||
<React.Suspense fallback={null}>
|
{settingsWindowMounted ? (
|
||||||
<SettingsWindow
|
<React.Suspense fallback={null}>
|
||||||
open={isSettingsDialogOpen}
|
<SettingsWindow
|
||||||
onOpenChange={setSettingsDialogOpen}
|
open={isSettingsDialogOpen}
|
||||||
/>
|
onOpenChange={setSettingsDialogOpen}
|
||||||
</React.Suspense>
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { cn } from '@/lib/utils';
|
||||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||||
@@ -15,8 +15,33 @@ import {
|
|||||||
} from '@/lib/terminalTouchSelection';
|
} from '@/lib/terminalTouchSelection';
|
||||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||||
|
|
||||||
let ghosttyPromise: Promise<Ghostty> | null = null;
|
// ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView
|
||||||
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
|
// 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<GhosttyRuntime> | null = null;
|
||||||
|
const loadGhostty = (): Promise<GhosttyRuntime> =>
|
||||||
|
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<void> => {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve();
|
||||||
|
const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise<void> }).__openchamberEnsureNerdFonts;
|
||||||
|
if (typeof loader !== 'function') return Promise.resolve();
|
||||||
|
return Promise.race([
|
||||||
|
Promise.resolve(loader()).catch(() => undefined),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)),
|
||||||
|
]).then(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
type TerminalSize = { cols: number; rows: number };
|
type TerminalSize = { cols: number; rows: number };
|
||||||
|
|
||||||
@@ -211,13 +236,13 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
window.addEventListener('focus', handleWindowFocus);
|
window.addEventListener('focus', handleWindowFocus);
|
||||||
window.addEventListener('blur', handleWindowBlur);
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
|
|
||||||
loadGhostty().then((ghostty) => {
|
Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
terminal = new GhosttyTerminal({
|
terminal = new module.Terminal({
|
||||||
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
||||||
...(provisionalSizeRef.current ?? {}),
|
...(provisionalSizeRef.current ?? {}),
|
||||||
});
|
});
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new module.FitAddon();
|
||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
terminal.open(container);
|
terminal.open(container);
|
||||||
terminalRef.current = terminal;
|
terminalRef.current = terminal;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntim
|
|||||||
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
||||||
|
|
||||||
const EMPTY_PINNED_SESSION_IDS = new Set<string>();
|
const EMPTY_PINNED_SESSION_IDS = new Set<string>();
|
||||||
import { getSettingsNavIcon } from '@/components/views/SettingsView';
|
import { getSettingsNavIcon } from '@/lib/settings/metadata';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { McpIcon } from '@/components/icons/McpIcon';
|
import { McpIcon } from '@/components/icons/McpIcon';
|
||||||
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRunti
|
|||||||
import { isWindowsArm64 as isWindowsArm64Platform } from '@/lib/platform';
|
import { isWindowsArm64 as isWindowsArm64Platform } from '@/lib/platform';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import type { IconName } from "@/components/icon/icons";
|
|
||||||
import { McpIcon } from '@/components/icons/McpIcon';
|
import { McpIcon } from '@/components/icons/McpIcon';
|
||||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||||
import {
|
import {
|
||||||
SETTINGS_PAGE_METADATA,
|
SETTINGS_PAGE_METADATA,
|
||||||
|
getSettingsNavIcon,
|
||||||
getSettingsPageMeta,
|
getSettingsPageMeta,
|
||||||
resolveSettingsSlug,
|
resolveSettingsSlug,
|
||||||
type SettingsPageSlug,
|
type SettingsPageSlug,
|
||||||
@@ -113,7 +113,6 @@ const pageOrder: SettingsPageSlug[] = [
|
|||||||
|
|
||||||
const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const;
|
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__';
|
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
|
||||||
|
|
||||||
function buildRuntimeContext(isDesktop: boolean, isMobile: boolean): SettingsRuntimeContext {
|
function buildRuntimeContext(isDesktop: boolean, isMobile: boolean): SettingsRuntimeContext {
|
||||||
@@ -171,65 +170,6 @@ function getCurrentHistoryState(): Record<string, unknown> {
|
|||||||
return window.history.state;
|
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<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
|
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import React, { useMemo, useEffect } from 'react';
|
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||||
import type { SupportedLanguages } from '@pierre/diffs';
|
import type { SupportedLanguages } from '@pierre/diffs';
|
||||||
import { WorkerPoolManager } from '@pierre/diffs/worker';
|
import type { WorkerPoolManager } from '@pierre/diffs/worker';
|
||||||
|
|
||||||
import { useOptionalThemeSystem } from './useThemeSystem';
|
import { useOptionalThemeSystem } from './useThemeSystem';
|
||||||
import { workerFactory } from '@/lib/diff/workerFactory';
|
|
||||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
|
||||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||||
|
import type { Theme } from '@/types/theme';
|
||||||
// NOTE: keep provider lightweight; avoid main-thread diff parsing here.
|
// 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
|
// Preload common languages for faster initial diff rendering
|
||||||
const PRELOAD_LANGS: SupportedLanguages[] = [
|
const PRELOAD_LANGS: SupportedLanguages[] = [
|
||||||
@@ -38,68 +42,90 @@ const WORKER_POOL_CONFIG: Record<WorkerPoolStyle, { poolSize: number; totalASTLR
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let unifiedWorkerPool: WorkerPoolManager | undefined;
|
type PoolModules = {
|
||||||
let splitWorkerPool: WorkerPoolManager | undefined;
|
WorkerPoolManager: typeof WorkerPoolManager;
|
||||||
|
workerFactory: () => Worker;
|
||||||
|
ensurePierreThemeRegistered: (theme: Theme) => void;
|
||||||
|
};
|
||||||
|
|
||||||
const createWorkerPool = (style: WorkerPoolStyle) => {
|
let poolModulesPromise: Promise<PoolModules> | null = null;
|
||||||
const config = WORKER_POOL_CONFIG[style];
|
|
||||||
const pool = new WorkerPoolManager(
|
const loadPoolModules = (): Promise<PoolModules> => {
|
||||||
{
|
poolModulesPromise ??= Promise.all([
|
||||||
workerFactory,
|
import('@pierre/diffs/worker'),
|
||||||
poolSize: config.poolSize,
|
import('@/lib/diff/workerFactory'),
|
||||||
totalASTLRUCacheSize: config.totalASTLRUCacheSize,
|
import('@/lib/shiki/appThemeRegistry'),
|
||||||
},
|
]).then(([workerModule, factoryModule, themeRegistryModule]) => ({
|
||||||
{
|
WorkerPoolManager: workerModule.WorkerPoolManager,
|
||||||
theme: {
|
workerFactory: factoryModule.workerFactory,
|
||||||
light: 'pierre-light',
|
ensurePierreThemeRegistered: themeRegistryModule.ensurePierreThemeRegistered,
|
||||||
dark: 'pierre-dark',
|
}));
|
||||||
|
return poolModulesPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pools: Partial<Record<WorkerPoolStyle, WorkerPoolManager>> = {};
|
||||||
|
const poolsRequested = new Set<WorkerPoolStyle>();
|
||||||
|
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,
|
theme: {
|
||||||
preferredHighlighter: 'shiki-wasm',
|
light: 'pierre-light',
|
||||||
}
|
dark: 'pierre-dark',
|
||||||
);
|
},
|
||||||
void pool.initialize();
|
langs: PRELOAD_LANGS,
|
||||||
return pool;
|
lineDiffType: config.lineDiffType,
|
||||||
|
preferredHighlighter: 'shiki-wasm',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
void pool.initialize();
|
||||||
|
pools[style] = pool;
|
||||||
|
applyRenderOptions(style, pool);
|
||||||
|
notifyPoolListeners();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getWorkerPool = (style: WorkerPoolStyle): WorkerPoolManager | undefined => {
|
const subscribeToPools = (listener: () => void): (() => void) => {
|
||||||
if (typeof window === 'undefined') {
|
poolListeners.add(listener);
|
||||||
return undefined;
|
return () => poolListeners.delete(listener);
|
||||||
}
|
|
||||||
|
|
||||||
if (style === 'split') {
|
|
||||||
splitWorkerPool ??= createWorkerPool('split');
|
|
||||||
return splitWorkerPool;
|
|
||||||
}
|
|
||||||
|
|
||||||
unifiedWorkerPool ??= createWorkerPool('unified');
|
|
||||||
return unifiedWorkerPool;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const WorkerPoolWarmup: React.FC<{
|
const setRenderTheme = (renderTheme: { light: string; dark: string }) => {
|
||||||
children: React.ReactNode;
|
if (currentRenderTheme.light === renderTheme.light && currentRenderTheme.dark === renderTheme.dark) {
|
||||||
renderTheme: { light: string; dark: string };
|
return;
|
||||||
}> = ({ children, renderTheme }) => {
|
}
|
||||||
const unifiedPool = useWorkerPool('unified');
|
currentRenderTheme = renderTheme;
|
||||||
const splitPool = useWorkerPool('split');
|
for (const style of Object.keys(pools) as WorkerPoolStyle[]) {
|
||||||
|
const pool = pools[style];
|
||||||
useEffect(() => {
|
if (pool) applyRenderOptions(style, pool);
|
||||||
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}</>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
|
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
|
||||||
@@ -118,25 +144,54 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
|
|||||||
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
|
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
|
||||||
fallbackDark;
|
fallbackDark;
|
||||||
|
|
||||||
ensurePierreThemeRegistered(lightTheme);
|
// Register the active app themes with @pierre/diffs and forward them to any
|
||||||
ensurePierreThemeRegistered(darkTheme);
|
// 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(
|
// 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:
|
||||||
light: lightTheme.metadata.id,
|
// warming competed with initial load (3 workers, shiki grammars, oniguruma
|
||||||
dark: darkTheme.metadata.id,
|
// wasm) when it ran during mount.
|
||||||
}),
|
useEffect(() => {
|
||||||
[darkTheme.metadata.id, lightTheme.metadata.id],
|
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 (
|
return <>{children}</>;
|
||||||
<WorkerPoolWarmup renderTheme={renderTheme}>
|
|
||||||
{children}
|
|
||||||
</WorkerPoolWarmup>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useWorkerPool = (style: WorkerPoolStyle = 'unified'): WorkerPoolManager | undefined => {
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SidebarSection } from '@/constants/sidebar';
|
import type { SidebarSection } from '@/constants/sidebar';
|
||||||
|
import type { IconName } from '@/components/icon/icons';
|
||||||
|
|
||||||
export type SettingsPageSlug =
|
export type SettingsPageSlug =
|
||||||
| 'home'
|
| 'home'
|
||||||
@@ -237,3 +238,65 @@ export function resolveSettingsSlug(value: string | null | undefined): SettingsP
|
|||||||
|
|
||||||
return 'home';
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+29
-25
@@ -15,12 +15,6 @@
|
|||||||
<link rel="apple-touch-icon" sizes="167x167" href="/apple-touch-icon-167x167.png" />
|
<link rel="apple-touch-icon" sizes="167x167" href="/apple-touch-icon-167x167.png" />
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon-152x152.png" />
|
<link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon-152x152.png" />
|
||||||
|
|
||||||
<!-- Preload Nerd Fonts for terminal icon display -->
|
|
||||||
<link rel="preload" href="https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/JetBrainsMonoNerdFont-Regular.woff2"
|
|
||||||
as="font" type="font/woff2" crossorigin="anonymous">
|
|
||||||
<link rel="preload" href="https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/FiraCodeNerdFont-Regular.woff2"
|
|
||||||
as="font" type="font/woff2" crossorigin="anonymous">
|
|
||||||
|
|
||||||
<!-- Web app manifest (endpoint-first with data URL fallback) -->
|
<!-- Web app manifest (endpoint-first with data URL fallback) -->
|
||||||
<script>
|
<script>
|
||||||
const baseUrl = location.origin;
|
const baseUrl = location.origin;
|
||||||
@@ -599,31 +593,41 @@
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CSS Font Loading API for reliable Nerd Font loading -->
|
<!-- CSS Font Loading API for reliable Nerd Font loading. The ~2 MB of
|
||||||
|
Nerd Fonts are only used by the terminal viewport, so they are no
|
||||||
|
longer downloaded on every cold start: the terminal requests them on
|
||||||
|
first mount through this idempotent hook. -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const fonts = [
|
let nerdFontsPromise = null;
|
||||||
{
|
window.__openchamberEnsureNerdFonts = function() {
|
||||||
name: 'JetBrainsMono Nerd Font',
|
if (nerdFontsPromise) {
|
||||||
url: 'https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/JetBrainsMonoNerdFont-Regular.woff2'
|
return nerdFontsPromise;
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FiraCode Nerd Font',
|
|
||||||
url: 'https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/FiraCodeNerdFont-Regular.woff2'
|
|
||||||
}
|
}
|
||||||
];
|
const fonts = [
|
||||||
|
{
|
||||||
|
name: 'JetBrainsMono Nerd Font',
|
||||||
|
url: 'https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/JetBrainsMonoNerdFont-Regular.woff2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FiraCode Nerd Font',
|
||||||
|
url: 'https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/FiraCodeNerdFont-Regular.woff2'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const fontPromises = fonts.map(font => {
|
const fontPromises = fonts.map(font => {
|
||||||
const fontFace = new FontFace(font.name, `url(${font.url}) format('woff2')`);
|
const fontFace = new FontFace(font.name, `url(${font.url}) format('woff2')`);
|
||||||
document.fonts.add(fontFace);
|
document.fonts.add(fontFace);
|
||||||
return fontFace.load().catch(err => {
|
return fontFace.load().catch(err => {
|
||||||
console.warn(`Failed to load font: ${font.name}`, err);
|
console.warn(`Failed to load font: ${font.name}`, err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
Promise.allSettled(fontPromises).then(() => {
|
nerdFontsPromise = Promise.allSettled(fontPromises).then(() => {
|
||||||
document.documentElement.classList.add('fonts-loaded');
|
document.documentElement.classList.add('fonts-loaded');
|
||||||
});
|
});
|
||||||
|
return nerdFontsPromise;
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,28 @@ export default defineConfig({
|
|||||||
const segments = match.split('/');
|
const segments = match.split('/');
|
||||||
const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0];
|
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 === 'react' || packageName === 'react-dom') return 'vendor-react';
|
||||||
if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand';
|
if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand';
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,28 @@ export default defineConfig({
|
|||||||
const segments = match.split('/')
|
const segments = match.split('/')
|
||||||
const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0]
|
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 === 'react' || packageName === 'react-dom') return 'vendor-react'
|
||||||
if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand'
|
if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand'
|
||||||
if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk'
|
if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk'
|
||||||
|
|||||||
Reference in New Issue
Block a user