From 578b1c2f86f5a13859e30697d2ba23c320f30805 Mon Sep 17 00:00:00 2001 From: Andrea V <1577639+karimodm@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:14:13 +0200 Subject: [PATCH] fix(chat): bound oversized tool diff previews (#3293) Closes #3292. Keep #3286 open for the separate context-panel DiffView path. Thanks for fixing oversized tool-card previews. --- .../chat/message/parts/DOCUMENTATION.md | 2 +- .../chat/message/parts/PlainDiffFallback.tsx | 28 +++++---- .../chat/message/parts/ToolPart.tsx | 15 +++-- .../chat/message/parts/toolDiffPreview.ts | 29 +++++++++ .../chat/message/parts/toolDiffUtils.test.ts | 61 +++++++++++++++++++ .../chat/message/parts/toolDiffUtils.ts | 11 ++++ 6 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/components/chat/message/parts/toolDiffPreview.ts diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 2f48b596..2efb7c7a 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -86,7 +86,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`. +- 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. Patches over 256 KiB or 2,000 lines skip rich parsing and use a bounded plain-text preview; navigation keeps the original patch. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`. - The `@pierre/diffs` stack is knowingly unprotected against the JS/TS `template-call` backtracking that OOM'd the renderer in openchamber/openchamber#2587. Our own markdown Shiki worker sanitizes every grammar it loads (`@/lib/shiki/sanitizeTemplateCallGrammar`), but the diff worker pool runs `preferredHighlighter: 'shiki-wasm'` (`DiffWorkerProvider.tsx`) and resolves its languages by id through `@pierre/diffs`' own registry — `langs` accepts `SupportedLanguages` strings only, so there is no seam to hand it a pre-sanitized `LanguageRegistration`. A pathological template literal inside a rendered diff can therefore still hang that pool's Oniguruma engine. The available levers are upstream (a `langs` overload accepting grammar objects) or switching that pool to the JS regex engine; neither is done. - Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes 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`). diff --git a/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx b/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx index 6e6e91e8..ae78cac2 100644 --- a/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx +++ b/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx @@ -1,19 +1,25 @@ import React from 'react'; +import { getToolDiffPreviewText } from './toolDiffPreview'; + /** * Plain-text patch rendering used when the rich `@pierre/diffs` preview is * unavailable: non-diff render modes, preview errors, and while the lazily * loaded diff preview chunk is still downloading. Lives in its own module so * `ToolPart` can render it without importing the @pierre/diffs stack. */ -export const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => ( -
- {diff}
-
-);
+export const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => {
+ const preview = React.useMemo(() => getToolDiffPreviewText(diff), [diff]);
+
+ return (
+
+ {preview}
+
+ );
+};
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx
index 29425e2e..64dad914 100644
--- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx
+++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx
@@ -22,6 +22,7 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { ToolPopupContent } from '../types';
import { PlainDiffFallback } from './PlainDiffFallback';
+import { isToolDiffPreviewOversized } from './toolDiffPreview';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import {
@@ -1200,11 +1201,15 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
// Suspense fallback, mirroring the preview's own error fallback.
const LazyToolPartDiffPreview = lazyWithChunkRecovery(() => import('./ToolPartDiffPreview'));
-const DiffPreview: React.FC<{ diff: string; diffViewMode: DiffViewMode }> = ({ diff, diffViewMode }) => (
-