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 { type ContentBlock, type ParsedMessage } from '@main/types';
|
||||||
import { createSafeRegExp } from '@main/utils/regexValidation';
|
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
|
// Pattern Matching
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -18,9 +55,10 @@ import { createSafeRegExp } from '@main/utils/regexValidation';
|
||||||
/**
|
/**
|
||||||
* Checks if content matches a pattern.
|
* Checks if content matches a pattern.
|
||||||
* Uses validated regex to prevent ReDoS attacks.
|
* 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 {
|
export function matchesPattern(content: string, pattern: string): boolean {
|
||||||
const regex = createSafeRegExp(pattern, 'i');
|
const regex = getCachedRegex(pattern, 'i');
|
||||||
if (!regex) {
|
if (!regex) {
|
||||||
// Pattern is invalid or potentially dangerous, reject match
|
// Pattern is invalid or potentially dangerous, reject match
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -31,6 +69,7 @@ export function matchesPattern(content: string, pattern: string): boolean {
|
||||||
/**
|
/**
|
||||||
* Checks if content matches any of the ignore patterns.
|
* Checks if content matches any of the ignore patterns.
|
||||||
* Uses validated regex to prevent ReDoS attacks.
|
* 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 {
|
export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]): boolean {
|
||||||
if (!ignorePatterns || ignorePatterns.length === 0) {
|
if (!ignorePatterns || ignorePatterns.length === 0) {
|
||||||
|
|
@ -38,7 +77,7 @@ export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pattern of ignorePatterns) {
|
for (const pattern of ignorePatterns) {
|
||||||
const regex = createSafeRegExp(pattern, 'i');
|
const regex = getCachedRegex(pattern, 'i');
|
||||||
if (regex?.test(content)) {
|
if (regex?.test(content)) {
|
||||||
return true;
|
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 React from 'react';
|
||||||
|
|
||||||
import { type ItemStatus, StatusDot } from '../BaseItem';
|
import { type ItemStatus } from '../BaseItem';
|
||||||
|
|
||||||
|
import { CollapsibleOutputSection } from './CollapsibleOutputSection';
|
||||||
import { renderInput, renderOutput } from './renderHelpers';
|
import { renderInput, renderOutput } from './renderHelpers';
|
||||||
|
|
||||||
import type { LinkedToolItem } from '@renderer/types/groups';
|
import type { LinkedToolItem } from '@renderer/types/groups';
|
||||||
|
|
@ -37,30 +38,11 @@ export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Output Section */}
|
{/* Output Section — Collapsed by default */}
|
||||||
{!linkedTool.isOrphaned && linkedTool.result && (
|
{!linkedTool.isOrphaned && linkedTool.result && (
|
||||||
<div>
|
<CollapsibleOutputSection status={status}>
|
||||||
<div
|
{renderOutput(linkedTool.result.content)}
|
||||||
className="mb-1 flex items-center gap-2 text-xs"
|
</CollapsibleOutputSection>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import React from 'react';
|
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';
|
import type { LinkedToolItem } from '@renderer/types/groups';
|
||||||
|
|
||||||
|
|
@ -54,12 +54,49 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
|
||||||
? startLine + limit - 1
|
? startLine + limit - 1
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const isMarkdownFile = /\.mdx?$/i.test(filePath);
|
||||||
|
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeBlockViewer
|
<div className="space-y-2">
|
||||||
fileName={filePath}
|
{isMarkdownFile && (
|
||||||
content={content}
|
<div className="flex items-center justify-end gap-1">
|
||||||
startLine={startLine}
|
<button
|
||||||
endLine={endLine}
|
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 content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || '';
|
||||||
const isCreate = toolUseResult?.type === 'create';
|
const isCreate = toolUseResult?.type === 'create';
|
||||||
const isMarkdownFile = /\.mdx?$/i.test(filePath);
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
* Exports all specialized tool viewer components.
|
* Exports all specialized tool viewer components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export { CollapsibleOutputSection } from './CollapsibleOutputSection';
|
||||||
export { DefaultToolViewer } from './DefaultToolViewer';
|
export { DefaultToolViewer } from './DefaultToolViewer';
|
||||||
export { EditToolViewer } from './EditToolViewer';
|
export { EditToolViewer } from './EditToolViewer';
|
||||||
export { ReadToolViewer } from './ReadToolViewer';
|
export { ReadToolViewer } from './ReadToolViewer';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue