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:
Serhii Dziupin
2026-08-07 09:01:31 +03:00
committed by GitHub
co-authored by Serhii Dziupin
parent da3d467f82
commit bc380e6e1b
21 changed files with 585 additions and 380 deletions
+23 -6
View File
@@ -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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
submitting={reviewFlowSubmitting}
onConfirm={handleStartReviewFlow}
/>
<ToolOutputDialog
popup={attachmentPreview}
onOpenChange={handleAttachmentPreviewOpenChange}
isMobile={isMobile}
/>
{attachmentPreviewMounted ? (
<React.Suspense fallback={null}>
<ToolOutputDialog
popup={attachmentPreview}
onOpenChange={handleAttachmentPreviewOpenChange}
isMobile={isMobile}
/>
</React.Suspense>
) : null}
{/* Single always-mounted picker input. It must NOT live inside
ComposerAttachmentControls: that component mounts once per composer
@@ -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';
@@ -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,
@@ -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 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<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`.
- 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`
@@ -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 { 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 }) => (
<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>
// 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 }) => (
<React.Suspense fallback={<PlainDiffFallback diff={diff} />}>
<LazyToolPartDiffPreview diff={diff} diffViewMode={diffViewMode} />
</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 {
part: ToolPartType;
state: ToolStateUnion;
@@ -1357,7 +1230,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const { t } = useI18n();
const runtime = React.useContext(RuntimeAPIContext);
const mobileActions = useMobileAppActions();
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
@@ -1633,8 +1505,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
{entry.renderMode === 'diff' ? (
<DiffPreview
diff={entry.patch}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
diffViewMode={diffViewMode}
/>
) : (
@@ -1753,8 +1623,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
) : isWriteLikeTool && writeLikeInputPatch ? (
<DiffPreview
diff={writeLikeInputPatch}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
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 { 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 ──