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)
This commit is contained in:
parent
7324b5236d
commit
4946a65a7b
6 changed files with 150 additions and 34 deletions
|
|
@ -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<string, RegExp | null>();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CollapsibleOutputSectionProps> = ({
|
||||
status,
|
||||
children,
|
||||
label = 'Output',
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1 flex items-center gap-2 text-xs"
|
||||
style={{ color: 'var(--tool-item-muted)', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
{label}
|
||||
<StatusDot status={status} />
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--code-bg)',
|
||||
border: '1px solid var(--code-border)',
|
||||
color:
|
||||
status === 'error'
|
||||
? 'var(--tool-result-error-text)'
|
||||
: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<DefaultToolViewerProps> = ({ linkedTool
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Section */}
|
||||
{/* Output Section — Collapsed by default */}
|
||||
{!linkedTool.isOrphaned && linkedTool.result && (
|
||||
<div>
|
||||
<div
|
||||
className="mb-1 flex items-center gap-2 text-xs"
|
||||
style={{ color: 'var(--tool-item-muted)' }}
|
||||
>
|
||||
Output
|
||||
<StatusDot status={status} />
|
||||
</div>
|
||||
<div
|
||||
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--code-bg)',
|
||||
border: '1px solid var(--code-border)',
|
||||
color:
|
||||
status === 'error'
|
||||
? 'var(--tool-result-error-text)'
|
||||
: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{renderOutput(linkedTool.result.content)}
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleOutputSection status={status}>
|
||||
{renderOutput(linkedTool.result.content)}
|
||||
</CollapsibleOutputSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<ReadToolViewerProps> = ({ linkedTool }) =>
|
|||
? startLine + limit - 1
|
||||
: undefined;
|
||||
|
||||
const isMarkdownFile = /\.mdx?$/i.test(filePath);
|
||||
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code');
|
||||
|
||||
return (
|
||||
<CodeBlockViewer
|
||||
fileName={filePath}
|
||||
content={content}
|
||||
startLine={startLine}
|
||||
endLine={endLine}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{isMarkdownFile && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('code')}
|
||||
className="rounded px-2 py-1 text-xs transition-colors"
|
||||
style={{
|
||||
backgroundColor: viewMode === 'code' ? 'var(--tag-bg)' : 'transparent',
|
||||
color: viewMode === 'code' ? 'var(--tag-text)' : 'var(--color-text-muted)',
|
||||
border: '1px solid var(--tag-border)',
|
||||
}}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('preview')}
|
||||
className="rounded px-2 py-1 text-xs transition-colors"
|
||||
style={{
|
||||
backgroundColor: viewMode === 'preview' ? 'var(--tag-bg)' : 'transparent',
|
||||
color: viewMode === 'preview' ? 'var(--tag-text)' : 'var(--color-text-muted)',
|
||||
border: '1px solid var(--tag-border)',
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isMarkdownFile && viewMode === 'preview' ? (
|
||||
<MarkdownViewer content={content} label="Markdown Preview" copyable />
|
||||
) : (
|
||||
<CodeBlockViewer
|
||||
fileName={filePath}
|
||||
content={content}
|
||||
startLine={startLine}
|
||||
endLine={endLine}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ 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 (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue