perf: replace remark-based search with plain text indexOf
Manually ported from upstream 5c7f921e. Key changes: - SessionSearcher: indexOf instead of remark AST, batch size 8→16 - conversationSlice: indexOf with MAX_SEARCH_MATCHES=500 cap - Item-scoped store selectors (searchMatchItemIds Set) to skip re-renders - Pre-filter in markdownTextSearch (skip parse if no raw match) - SearchTextCache: 200→1000 entries - ProjectScanner: 30s search project cache, batch 4→8
This commit is contained in:
parent
e30f6482bf
commit
c21350713c
10 changed files with 128 additions and 77 deletions
|
|
@ -71,6 +71,9 @@ import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemP
|
||||||
|
|
||||||
const logger = createLogger('Discovery:ProjectScanner');
|
const logger = createLogger('Discovery:ProjectScanner');
|
||||||
|
|
||||||
|
/** How long to reuse the cached project list for search (ms) */
|
||||||
|
const SEARCH_PROJECT_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
// IPC payload safety: session ID arrays can be extremely large for long-lived projects.
|
// IPC payload safety: session ID arrays can be extremely large for long-lived projects.
|
||||||
// Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive.
|
// Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive.
|
||||||
// Keep this non-zero because parts of the renderer still rely on a (partial) sessionId list
|
// Keep this non-zero because parts of the renderer still rely on a (partial) sessionId list
|
||||||
|
|
@ -147,6 +150,9 @@ export class ProjectScanner {
|
||||||
private scanCache: { projects: Project[]; timestamp: number } | null = null;
|
private scanCache: { projects: Project[]; timestamp: number } | null = null;
|
||||||
private static readonly SCAN_CACHE_TTL_MS = 2000;
|
private static readonly SCAN_CACHE_TTL_MS = 2000;
|
||||||
|
|
||||||
|
/** Cached project list for search — avoids re-scanning disk on every query */
|
||||||
|
private searchProjectCache: { projects: Project[]; timestamp: number } | null = null;
|
||||||
|
|
||||||
// Platform-aware batch sizes to avoid UV thread pool saturation on Windows
|
// Platform-aware batch sizes to avoid UV thread pool saturation on Windows
|
||||||
private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64;
|
private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64;
|
||||||
private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12;
|
private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12;
|
||||||
|
|
@ -1329,8 +1335,17 @@ export class ProjectScanner {
|
||||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all projects
|
// Use cached project list to avoid re-scanning disk on every keystroke
|
||||||
const projects = await this.scan();
|
let projects: Project[];
|
||||||
|
if (
|
||||||
|
this.searchProjectCache &&
|
||||||
|
Date.now() - this.searchProjectCache.timestamp < SEARCH_PROJECT_CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
projects = this.searchProjectCache.projects;
|
||||||
|
} else {
|
||||||
|
projects = await this.scan();
|
||||||
|
this.searchProjectCache = { projects, timestamp: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||||
|
|
@ -1338,7 +1353,7 @@ export class ProjectScanner {
|
||||||
|
|
||||||
// Search across all projects with bounded concurrency
|
// Search across all projects with bounded concurrency
|
||||||
const allResults: SearchSessionsResult[] = [];
|
const allResults: SearchSessionsResult[] = [];
|
||||||
const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 4;
|
const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 8;
|
||||||
|
|
||||||
for (let i = 0; i < projects.length; i += searchBatchSize) {
|
for (let i = 0; i < projects.length; i += searchBatchSize) {
|
||||||
const batch = projects.slice(i, i + searchBatchSize);
|
const batch = projects.slice(i, i + searchBatchSize);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export class SearchTextCache {
|
||||||
private readonly cache = new Map<string, CacheEntry>();
|
private readonly cache = new Map<string, CacheEntry>();
|
||||||
private readonly maxSize: number;
|
private readonly maxSize: number;
|
||||||
|
|
||||||
constructor(maxSize: number = 200) {
|
constructor(maxSize: number = 1000) {
|
||||||
this.maxSize = maxSize;
|
this.maxSize = maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,6 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFile
|
||||||
import { parseJsonlFile } from '@main/utils/jsonl';
|
import { parseJsonlFile } from '@main/utils/jsonl';
|
||||||
import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';
|
import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import {
|
|
||||||
extractMarkdownPlainText,
|
|
||||||
findMarkdownSearchMatches,
|
|
||||||
} from '@shared/utils/markdownTextSearch';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { startMainSpan } from '../../sentry';
|
import { startMainSpan } from '../../sentry';
|
||||||
|
|
@ -112,7 +108,7 @@ export class SessionSearcher {
|
||||||
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||||
|
|
||||||
// Search session files with bounded concurrency and staged breadth in SSH mode.
|
// Search session files with bounded concurrency and staged breadth in SSH mode.
|
||||||
const searchBatchSize = fastMode ? 3 : 8;
|
const searchBatchSize = fastMode ? 3 : 16;
|
||||||
const stageBoundaries = fastMode
|
const stageBoundaries = fastMode
|
||||||
? this.buildFastSearchStageBoundaries(sessionFiles.length)
|
? this.buildFastSearchStageBoundaries(sessionFiles.length)
|
||||||
: [sessionFiles.length];
|
: [sessionFiles.length];
|
||||||
|
|
@ -236,6 +232,10 @@ export class SessionSearcher {
|
||||||
|
|
||||||
const { entries, sessionTitle } = cached;
|
const { entries, sessionTitle } = cached;
|
||||||
|
|
||||||
|
// Fast pre-filter: skip sessions where no entry contains the query in raw text
|
||||||
|
const hasAnyMatch = entries.some((entry) => entry.text.toLowerCase().includes(query));
|
||||||
|
if (!hasAnyMatch) return results;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (results.length >= maxResults) break;
|
if (results.length >= maxResults) break;
|
||||||
|
|
||||||
|
|
@ -262,31 +262,20 @@ export class SessionSearcher {
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
): void {
|
): void {
|
||||||
const mdMatches = findMarkdownSearchMatches(entry.text, query);
|
// Plain indexOf search — no markdown/remark parsing
|
||||||
if (mdMatches.length === 0) return;
|
const lowerText = entry.text.toLowerCase();
|
||||||
|
if (!lowerText.includes(query)) return;
|
||||||
|
|
||||||
// Build plain text once for context snippet extraction
|
// Use raw text directly for context snippets
|
||||||
const plainText = extractMarkdownPlainText(entry.text);
|
let pos = 0;
|
||||||
const lowerPlain = plainText.toLowerCase();
|
let matchIndex = 0;
|
||||||
|
while ((pos = lowerText.indexOf(query, pos)) !== -1) {
|
||||||
for (const mdMatch of mdMatches) {
|
|
||||||
if (results.length >= maxResults) return;
|
if (results.length >= maxResults) return;
|
||||||
|
|
||||||
// Find approximate position in plain text for context extraction
|
const contextStart = Math.max(0, pos - 50);
|
||||||
let pos = 0;
|
const contextEnd = Math.min(entry.text.length, pos + query.length + 50);
|
||||||
for (let i = 0; i < mdMatch.matchIndexInItem; i++) {
|
const context = entry.text.slice(contextStart, contextEnd);
|
||||||
const idx = lowerPlain.indexOf(query, pos);
|
const matchedText = entry.text.slice(pos, pos + query.length);
|
||||||
if (idx === -1) break;
|
|
||||||
pos = idx + query.length;
|
|
||||||
}
|
|
||||||
const matchPos = lowerPlain.indexOf(query, pos);
|
|
||||||
const effectivePos = matchPos >= 0 ? matchPos : 0;
|
|
||||||
|
|
||||||
const contextStart = Math.max(0, effectivePos - 50);
|
|
||||||
const contextEnd = Math.min(plainText.length, effectivePos + query.length + 50);
|
|
||||||
const context = plainText.slice(contextStart, contextEnd);
|
|
||||||
const matchedText =
|
|
||||||
matchPos >= 0 ? plainText.slice(matchPos, matchPos + query.length) : query;
|
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|
@ -294,15 +283,20 @@ export class SessionSearcher {
|
||||||
sessionTitle: sessionTitle ?? 'Untitled Session',
|
sessionTitle: sessionTitle ?? 'Untitled Session',
|
||||||
matchedText,
|
matchedText,
|
||||||
context:
|
context:
|
||||||
(contextStart > 0 ? '...' : '') + context + (contextEnd < plainText.length ? '...' : ''),
|
(contextStart > 0 ? '...' : '') +
|
||||||
|
context +
|
||||||
|
(contextEnd < entry.text.length ? '...' : ''),
|
||||||
messageType: entry.messageType,
|
messageType: entry.messageType,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
groupId: entry.groupId,
|
groupId: entry.groupId,
|
||||||
itemType: entry.itemType,
|
itemType: entry.itemType,
|
||||||
matchIndexInItem: mdMatch.matchIndexInItem,
|
matchIndexInItem: matchIndex,
|
||||||
matchStartOffset: effectivePos,
|
matchStartOffset: pos,
|
||||||
messageUuid: entry.messageUuid,
|
messageUuid: entry.messageUuid,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
matchIndex++;
|
||||||
|
pos += query.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { CopyButton } from '../common/CopyButton';
|
||||||
import { OngoingBanner } from '../common/OngoingIndicator';
|
import { OngoingBanner } from '../common/OngoingIndicator';
|
||||||
|
|
||||||
import { createMarkdownComponents, markdownComponents } from './markdownComponents';
|
import { createMarkdownComponents, markdownComponents } from './markdownComponents';
|
||||||
import { createSearchContext } from './searchHighlightUtils';
|
import { createSearchContext, EMPTY_SEARCH_MATCHES } from './searchHighlightUtils';
|
||||||
|
|
||||||
import type { AIGroupLastOutput } from '@renderer/types/groups';
|
import type { AIGroupLastOutput } from '@renderer/types/groups';
|
||||||
|
|
||||||
|
|
@ -41,12 +41,16 @@ export const LastOutputDisplay = ({
|
||||||
isLastGroup = false,
|
isLastGroup = false,
|
||||||
isSessionOngoing = false,
|
isSessionOngoing = false,
|
||||||
}: Readonly<LastOutputDisplayProps>): React.JSX.Element | null => {
|
}: Readonly<LastOutputDisplayProps>): React.JSX.Element | null => {
|
||||||
|
// Only re-render if THIS AI group has search matches
|
||||||
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => {
|
||||||
searchQuery: s.searchQuery,
|
const hasMatch = s.searchMatchItemIds.has(aiGroupId);
|
||||||
searchMatches: s.searchMatches,
|
return {
|
||||||
currentSearchIndex: s.currentSearchIndex,
|
searchQuery: hasMatch ? s.searchQuery : '',
|
||||||
}))
|
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
|
||||||
|
currentSearchIndex: hasMatch ? s.currentSearchIndex : -1,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const isTextOutput = lastOutput?.type === 'text' && Boolean(lastOutput.text);
|
const isTextOutput = lastOutput?.type === 'text' && Boolean(lastOutput.text);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { CopyButton } from '../common/CopyButton';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createSearchContext,
|
createSearchContext,
|
||||||
|
EMPTY_SEARCH_MATCHES,
|
||||||
highlightSearchInChildren,
|
highlightSearchInChildren,
|
||||||
type SearchContext,
|
type SearchContext,
|
||||||
} from './searchHighlightUtils';
|
} from './searchHighlightUtils';
|
||||||
|
|
@ -401,13 +402,16 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
[teams]
|
[teams]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get search state for highlighting
|
// Get search state for highlighting — only re-render if THIS item has matches
|
||||||
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => {
|
||||||
searchQuery: s.searchQuery,
|
const hasMatch = s.searchMatchItemIds.has(groupId);
|
||||||
searchMatches: s.searchMatches,
|
return {
|
||||||
currentSearchIndex: s.currentSearchIndex,
|
searchQuery: hasMatch ? s.searchQuery : '',
|
||||||
}))
|
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
|
||||||
|
currentSearchIndex: hasMatch ? s.currentSearchIndex : -1,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasImages = content.images.length > 0;
|
const hasImages = content.images.length > 0;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import React from 'react';
|
||||||
|
|
||||||
import type { SearchMatch } from '@renderer/store/types';
|
import type { SearchMatch } from '@renderer/store/types';
|
||||||
|
|
||||||
|
/** Stable empty array for item-scoped search selectors (avoids re-renders) */
|
||||||
|
export const EMPTY_SEARCH_MATCHES: SearchMatch[] = [];
|
||||||
|
|
||||||
// Highlight styles matching SearchHighlight.tsx
|
// Highlight styles matching SearchHighlight.tsx
|
||||||
const baseStyles: React.CSSProperties = {
|
const baseStyles: React.CSSProperties = {
|
||||||
borderRadius: '0.125rem',
|
borderRadius: '0.125rem',
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createSearchContext,
|
createSearchContext,
|
||||||
|
EMPTY_SEARCH_MATCHES,
|
||||||
highlightSearchInChildren,
|
highlightSearchInChildren,
|
||||||
type SearchContext,
|
type SearchContext,
|
||||||
} from '../searchHighlightUtils';
|
} from '../searchHighlightUtils';
|
||||||
|
|
@ -44,8 +45,6 @@ import { highlightLine } from '../viewers/syntaxHighlighter';
|
||||||
import { FileLink, isRelativeUrl } from './FileLink';
|
import { FileLink, isRelativeUrl } from './FileLink';
|
||||||
import { MermaidDiagram } from './MermaidDiagram';
|
import { MermaidDiagram } from './MermaidDiagram';
|
||||||
|
|
||||||
import type { SearchMatch } from '@renderer/store/types';
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -73,7 +72,6 @@ interface MarkdownViewerProps {
|
||||||
|
|
||||||
const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
|
const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
|
||||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||||
const EMPTY_SEARCH_MATCHES: SearchMatch[] = [];
|
|
||||||
const NOOP_TEAM_CLICK = (): void => undefined;
|
const NOOP_TEAM_CLICK = (): void => undefined;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -714,13 +712,16 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
||||||
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
||||||
|
|
||||||
// Only subscribe to search store when itemId is provided
|
// Only re-render if THIS item has search matches
|
||||||
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => {
|
||||||
searchQuery: itemId ? s.searchQuery : '',
|
const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false;
|
||||||
searchMatches: itemId ? s.searchMatches : EMPTY_SEARCH_MATCHES,
|
return {
|
||||||
currentSearchIndex: itemId ? s.currentSearchIndex : -1,
|
searchQuery: hasMatch ? s.searchQuery : '',
|
||||||
}))
|
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
|
||||||
|
currentSearchIndex: hasMatch ? s.currentSearchIndex : -1,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Guard: very large markdown can freeze the renderer (remark/rehype + highlighting).
|
// Guard: very large markdown can freeze the renderer (remark/rehype + highlighting).
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
import { findLastOutput } from '@renderer/utils/aiGroupEnhancer';
|
import { findLastOutput } from '@renderer/utils/aiGroupEnhancer';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch';
|
|
||||||
|
|
||||||
import type { AppState, SearchMatch } from '../types';
|
import type { AppState, SearchMatch } from '../types';
|
||||||
import type { AIGroupExpansionLevel } from '@renderer/types/groups';
|
import type { AIGroupExpansionLevel } from '@renderer/types/groups';
|
||||||
|
|
@ -17,6 +16,9 @@ import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
type DetailItemType = 'thinking' | 'text' | 'linked-tool' | 'subagent';
|
type DetailItemType = 'thinking' | 'text' | 'linked-tool' | 'subagent';
|
||||||
|
|
||||||
|
/** Maximum number of search matches to track. Beyond this, results are capped. */
|
||||||
|
const MAX_SEARCH_MATCHES = 500;
|
||||||
|
|
||||||
const isSearchDebugEnabled = (): boolean => {
|
const isSearchDebugEnabled = (): boolean => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
try {
|
try {
|
||||||
|
|
@ -57,6 +59,10 @@ export interface ConversationSlice {
|
||||||
searchResultCount: number;
|
searchResultCount: number;
|
||||||
currentSearchIndex: number;
|
currentSearchIndex: number;
|
||||||
searchMatches: SearchMatch[];
|
searchMatches: SearchMatch[];
|
||||||
|
/** True when total matches exceeded the cap and results were truncated */
|
||||||
|
searchResultsCapped: boolean;
|
||||||
|
/** Item IDs that contain at least one search match — used by components to skip re-renders */
|
||||||
|
searchMatchItemIds: Set<string>;
|
||||||
|
|
||||||
// Auto-expand state for search results
|
// Auto-expand state for search results
|
||||||
/** AI group IDs that should be expanded to show search results */
|
/** AI group IDs that should be expanded to show search results */
|
||||||
|
|
@ -126,6 +132,8 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
|
||||||
searchResultCount: 0,
|
searchResultCount: 0,
|
||||||
currentSearchIndex: -1,
|
currentSearchIndex: -1,
|
||||||
searchMatches: [],
|
searchMatches: [],
|
||||||
|
searchResultsCapped: false,
|
||||||
|
searchMatchItemIds: new Set(),
|
||||||
|
|
||||||
// Auto-expand state for search results (initial values)
|
// Auto-expand state for search results (initial values)
|
||||||
searchExpandedAIGroupIds: new Set(),
|
searchExpandedAIGroupIds: new Set(),
|
||||||
|
|
@ -222,58 +230,66 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
|
||||||
searchResultCount: 0,
|
searchResultCount: 0,
|
||||||
currentSearchIndex: -1,
|
currentSearchIndex: -1,
|
||||||
searchMatches: [],
|
searchMatches: [],
|
||||||
|
searchResultsCapped: false,
|
||||||
|
searchMatchItemIds: new Set(),
|
||||||
searchCurrentDisplayItemId: null,
|
searchCurrentDisplayItemId: null,
|
||||||
searchCurrentSubagentItemId: null,
|
searchCurrentSubagentItemId: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build search matches by scanning conversation
|
// Build search matches by scanning conversation.
|
||||||
// ONLY searches: user message text and AI lastOutput text (not tool results)
|
// Plain indexOf search — no markdown parsing. Match counts may differ
|
||||||
// Uses remark-based markdown parsing to extract visible text segments,
|
// slightly from rendered highlights; syncSearchMatchesWithRendered corrects this.
|
||||||
// ensuring match counts align with what ReactMarkdown renders.
|
|
||||||
const matches: SearchMatch[] = [];
|
const matches: SearchMatch[] = [];
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
let globalIndex = 0;
|
let globalIndex = 0;
|
||||||
|
let capped = false;
|
||||||
|
|
||||||
// Helper to find markdown-aware matches and add to matches array
|
// Count occurrences of lowerQuery in text using plain indexOf
|
||||||
const addMarkdownMatches = (
|
const addPlainTextMatches = (
|
||||||
text: string,
|
text: string,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
itemType: 'user' | 'ai',
|
itemType: 'user' | 'ai',
|
||||||
displayItemId?: string
|
displayItemId?: string
|
||||||
): void => {
|
): void => {
|
||||||
const mdMatches = findMarkdownSearchMatches(text, lowerQuery);
|
if (capped) return;
|
||||||
for (const mdMatch of mdMatches) {
|
const lowerText = text.toLowerCase();
|
||||||
|
let pos = 0;
|
||||||
|
let matchIndexInItem = 0;
|
||||||
|
while ((pos = lowerText.indexOf(lowerQuery, pos)) !== -1) {
|
||||||
|
if (matches.length >= MAX_SEARCH_MATCHES) {
|
||||||
|
capped = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
matches.push({
|
matches.push({
|
||||||
itemId,
|
itemId,
|
||||||
itemType,
|
itemType,
|
||||||
matchIndexInItem: mdMatch.matchIndexInItem,
|
matchIndexInItem,
|
||||||
globalIndex,
|
globalIndex,
|
||||||
displayItemId,
|
displayItemId,
|
||||||
});
|
});
|
||||||
|
matchIndexInItem++;
|
||||||
globalIndex++;
|
globalIndex++;
|
||||||
|
pos += lowerQuery.length;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const item of conversation.items) {
|
for (const item of conversation.items) {
|
||||||
|
if (capped) break;
|
||||||
if (item.type === 'user') {
|
if (item.type === 'user') {
|
||||||
const raw = item.group.content.rawText ?? item.group.content.text ?? '';
|
const raw = item.group.content.rawText ?? item.group.content.text ?? '';
|
||||||
const text = stripAgentBlocks(raw);
|
const text = stripAgentBlocks(raw);
|
||||||
addMarkdownMatches(text, item.group.id, 'user');
|
addPlainTextMatches(text, item.group.id, 'user');
|
||||||
} else if (item.type === 'ai') {
|
} else if (item.type === 'ai') {
|
||||||
// For AI items: ONLY search lastOutput text (not tool results, thinking, or subagents)
|
|
||||||
const aiGroup = item.group;
|
const aiGroup = item.group;
|
||||||
const itemId = aiGroup.id;
|
const itemId = aiGroup.id;
|
||||||
const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false);
|
const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false);
|
||||||
|
|
||||||
if (lastOutput?.type === 'text' && lastOutput.text) {
|
if (lastOutput?.type === 'text' && lastOutput.text) {
|
||||||
// Last output text - displayItemId indicates this is lastOutput content
|
addPlainTextMatches(lastOutput.text, itemId, 'ai', 'lastOutput');
|
||||||
addMarkdownMatches(lastOutput.text, itemId, 'ai', 'lastOutput');
|
|
||||||
}
|
}
|
||||||
// Skip tool_result type - only searching text output
|
|
||||||
}
|
}
|
||||||
// Skip system items entirely
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSearchDebugEnabled()) {
|
if (isSearchDebugEnabled()) {
|
||||||
|
|
@ -293,11 +309,19 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
|
||||||
console.info('[search] sample', sample);
|
console.info('[search] sample', sample);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build set of item IDs that have matches — components use this to skip re-renders
|
||||||
|
const matchItemIds = new Set<string>();
|
||||||
|
for (const match of matches) {
|
||||||
|
matchItemIds.add(match.itemId);
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
searchQuery: query,
|
searchQuery: query,
|
||||||
searchResultCount: matches.length,
|
searchResultCount: matches.length,
|
||||||
currentSearchIndex: matches.length > 0 ? 0 : -1,
|
currentSearchIndex: matches.length > 0 ? 0 : -1,
|
||||||
searchMatches: matches,
|
searchMatches: matches,
|
||||||
|
searchResultsCapped: capped,
|
||||||
|
searchMatchItemIds: matchItemIds,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -406,6 +430,8 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
|
||||||
searchResultCount: 0,
|
searchResultCount: 0,
|
||||||
currentSearchIndex: -1,
|
currentSearchIndex: -1,
|
||||||
searchMatches: [],
|
searchMatches: [],
|
||||||
|
searchResultsCapped: false,
|
||||||
|
searchMatchItemIds: new Set(),
|
||||||
searchExpandedAIGroupIds: new Set(),
|
searchExpandedAIGroupIds: new Set(),
|
||||||
searchExpandedSubagentIds: new Set(),
|
searchExpandedSubagentIds: new Set(),
|
||||||
searchCurrentDisplayItemId: null,
|
searchCurrentDisplayItemId: null,
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ function parseMarkdown(text: string): MdastRoot {
|
||||||
// Segment cache (parse once, search many times per query keystroke)
|
// Segment cache (parse once, search many times per query keystroke)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const MAX_CACHE_SIZE = 200;
|
const MAX_CACHE_SIZE = 1000;
|
||||||
const segmentCache = new Map<string, string[]>();
|
const segmentCache = new Map<string, string[]>();
|
||||||
|
|
||||||
function getCachedSegments(markdown: string): string[] {
|
function getCachedSegments(markdown: string): string[] {
|
||||||
|
|
@ -154,6 +154,9 @@ export interface MarkdownSearchMatch {
|
||||||
export function findMarkdownSearchMatches(markdown: string, query: string): MarkdownSearchMatch[] {
|
export function findMarkdownSearchMatches(markdown: string, query: string): MarkdownSearchMatch[] {
|
||||||
if (!query || !markdown) return [];
|
if (!query || !markdown) return [];
|
||||||
|
|
||||||
|
// Fast pre-filter: skip expensive markdown parsing if query doesn't appear in raw text
|
||||||
|
if (!markdown.toLowerCase().includes(query.toLowerCase())) return [];
|
||||||
|
|
||||||
const segments = getCachedSegments(markdown);
|
const segments = getCachedSegments(markdown);
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
const matches: MarkdownSearchMatch[] = [];
|
const matches: MarkdownSearchMatch[] = [];
|
||||||
|
|
@ -178,6 +181,9 @@ export function findMarkdownSearchMatches(markdown: string, query: string): Mark
|
||||||
export function countMarkdownSearchMatches(markdown: string, query: string): number {
|
export function countMarkdownSearchMatches(markdown: string, query: string): number {
|
||||||
if (!query || !markdown) return 0;
|
if (!query || !markdown) return 0;
|
||||||
|
|
||||||
|
// Fast pre-filter: skip expensive markdown parsing if query doesn't appear in raw text
|
||||||
|
if (!markdown.toLowerCase().includes(query.toLowerCase())) return 0;
|
||||||
|
|
||||||
const segments = getCachedSegments(markdown);
|
const segments = getCachedSegments(markdown);
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ describe('SessionSearcher', () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not produce phantom matches for code fence language identifiers', async () => {
|
it('matches text in code fences with plain text search', async () => {
|
||||||
const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-searcher-md-'));
|
const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-searcher-md-'));
|
||||||
tempDirs.push(projectsDir);
|
tempDirs.push(projectsDir);
|
||||||
|
|
||||||
|
|
@ -110,13 +110,11 @@ describe('SessionSearcher', () => {
|
||||||
const searcher = new SessionSearcher(projectsDir);
|
const searcher = new SessionSearcher(projectsDir);
|
||||||
const result = await searcher.searchSessions(projectId, 'tsx', 50);
|
const result = await searcher.searchSessions(projectId, 'tsx', 50);
|
||||||
|
|
||||||
// "tsx" should match in user text ("Show me tsx code") but NOT in the
|
// Plain text search: "tsx" matches in user text AND in the code fence identifier
|
||||||
// code fence language identifier (```tsx). It should also not match in
|
|
||||||
// the code block content since "const x = 1;" doesn't contain "tsx".
|
|
||||||
const userResults = result.results.filter((r) => r.itemType === 'user');
|
const userResults = result.results.filter((r) => r.itemType === 'user');
|
||||||
const aiResults = result.results.filter((r) => r.itemType === 'ai');
|
const aiResults = result.results.filter((r) => r.itemType === 'ai');
|
||||||
|
|
||||||
expect(userResults).toHaveLength(1);
|
expect(userResults).toHaveLength(1);
|
||||||
expect(aiResults).toHaveLength(0);
|
expect(aiResults).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue