From 4946a65a7b29656ccae54729adebf89ef3ea7385 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:58:38 +0200 Subject: [PATCH] feat: cherry-pick upstream CollapsibleOutputSection + TriggerMatcher regex cache Cherry-picked from upstream: - 516d0f6b: CollapsibleOutputSection with markdown preview toggle - e51c1fd1: cache compiled regexes in TriggerMatcher (perf) --- src/main/services/error/TriggerMatcher.ts | 43 +++++++++++++- .../linkedTool/CollapsibleOutputSection.tsx | 57 +++++++++++++++++++ .../items/linkedTool/DefaultToolViewer.tsx | 30 ++-------- .../chat/items/linkedTool/ReadToolViewer.tsx | 51 ++++++++++++++--- .../chat/items/linkedTool/WriteToolViewer.tsx | 2 +- .../components/chat/items/linkedTool/index.ts | 1 + 6 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx diff --git a/src/main/services/error/TriggerMatcher.ts b/src/main/services/error/TriggerMatcher.ts index 7e06c47c..dbf53900 100644 --- a/src/main/services/error/TriggerMatcher.ts +++ b/src/main/services/error/TriggerMatcher.ts @@ -11,6 +11,43 @@ import { type ContentBlock, type ParsedMessage } from '@main/types'; import { createSafeRegExp } from '@main/utils/regexValidation'; +// ============================================================================= +// Regex Cache +// ============================================================================= + +const MAX_CACHE_SIZE = 500; + +/** + * Module-level cache for compiled RegExp objects. + * Key: `${pattern}\0${flags}` (null byte separator avoids collisions). + * Value: compiled RegExp, or null if the pattern is invalid/dangerous. + */ +const regexCache = new Map(); + +/** + * Returns a cached RegExp for the given pattern and flags. + * Compiles and caches on first access; returns null for invalid patterns. + * Cache is bounded to MAX_CACHE_SIZE entries (oldest evicted first via Map insertion order). + */ +function getCachedRegex(pattern: string, flags: string): RegExp | null { + const key = `${pattern}\0${flags}`; + if (regexCache.has(key)) { + return regexCache.get(key) ?? null; + } + + // Evict oldest entries when cache is full + if (regexCache.size >= MAX_CACHE_SIZE) { + const firstKey = regexCache.keys().next().value; + if (firstKey !== undefined) { + regexCache.delete(firstKey); + } + } + + const regex = createSafeRegExp(pattern, flags); + regexCache.set(key, regex); + return regex; +} + // ============================================================================= // Pattern Matching // ============================================================================= @@ -18,9 +55,10 @@ import { createSafeRegExp } from '@main/utils/regexValidation'; /** * Checks if content matches a pattern. * Uses validated regex to prevent ReDoS attacks. + * Regex objects are cached to avoid recompilation on repeated calls. */ export function matchesPattern(content: string, pattern: string): boolean { - const regex = createSafeRegExp(pattern, 'i'); + const regex = getCachedRegex(pattern, 'i'); if (!regex) { // Pattern is invalid or potentially dangerous, reject match return false; @@ -31,6 +69,7 @@ export function matchesPattern(content: string, pattern: string): boolean { /** * Checks if content matches any of the ignore patterns. * Uses validated regex to prevent ReDoS attacks. + * Regex objects are cached to avoid recompilation on repeated calls. */ export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]): boolean { if (!ignorePatterns || ignorePatterns.length === 0) { @@ -38,7 +77,7 @@ export function matchesIgnorePatterns(content: string, ignorePatterns?: string[] } for (const pattern of ignorePatterns) { - const regex = createSafeRegExp(pattern, 'i'); + const regex = getCachedRegex(pattern, 'i'); if (regex?.test(content)) { return true; } diff --git a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx new file mode 100644 index 00000000..de6fb699 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx @@ -0,0 +1,57 @@ +/** + * CollapsibleOutputSection + * + * Reusable component that wraps tool output in a collapsed-by-default section. + * Shows a clickable header with label, StatusDot, and chevron toggle. + */ + +import React, { useState } from 'react'; + +import { ChevronDown, ChevronRight } from 'lucide-react'; + +import { type ItemStatus, StatusDot } from '../BaseItem'; + +interface CollapsibleOutputSectionProps { + status: ItemStatus; + children: React.ReactNode; + /** Label shown in the header (default: "Output") */ + label?: string; +} + +export const CollapsibleOutputSection: React.FC = ({ + status, + children, + label = 'Output', +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + {isExpanded && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index 1be3f906..c0a06fcf 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -6,8 +6,9 @@ import React from 'react'; -import { type ItemStatus, StatusDot } from '../BaseItem'; +import { type ItemStatus } from '../BaseItem'; +import { CollapsibleOutputSection } from './CollapsibleOutputSection'; import { renderInput, renderOutput } from './renderHelpers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -37,30 +38,11 @@ export const DefaultToolViewer: React.FC = ({ linkedTool - {/* Output Section */} + {/* Output Section — Collapsed by default */} {!linkedTool.isOrphaned && linkedTool.result && ( -
-
- Output - -
-
- {renderOutput(linkedTool.result.content)} -
-
+ + {renderOutput(linkedTool.result.content)} + )} ); diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx index f0eeb688..4edd8c93 100644 --- a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { CodeBlockViewer } from '@renderer/components/chat/viewers'; +import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -54,12 +54,49 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => ? startLine + limit - 1 : undefined; + const isMarkdownFile = /\.mdx?$/i.test(filePath); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); + return ( - +
+ {isMarkdownFile && ( +
+ + +
+ )} + {isMarkdownFile && viewMode === 'preview' ? ( + + ) : ( + + )} +
); }; diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx index d08d7005..14fba8aa 100644 --- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -21,7 +21,7 @@ export const WriteToolViewer: React.FC = ({ linkedTool }) const content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || ''; const isCreate = toolUseResult?.type === 'create'; const isMarkdownFile = /\.mdx?$/i.test(filePath); - const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code'); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); return (
diff --git a/src/renderer/components/chat/items/linkedTool/index.ts b/src/renderer/components/chat/items/linkedTool/index.ts index 5c415dac..83f37b92 100644 --- a/src/renderer/components/chat/items/linkedTool/index.ts +++ b/src/renderer/components/chat/items/linkedTool/index.ts @@ -4,6 +4,7 @@ * Exports all specialized tool viewer components. */ +export { CollapsibleOutputSection } from './CollapsibleOutputSection'; export { DefaultToolViewer } from './DefaultToolViewer'; export { EditToolViewer } from './EditToolViewer'; export { ReadToolViewer } from './ReadToolViewer';