From 4946a65a7b29656ccae54729adebf89ef3ea7385 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:58:38 +0200 Subject: [PATCH 01/11] 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'; From 6fa075de51a294453848f4ac7287c53d1a7ad04d Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:58:48 +0200 Subject: [PATCH 02/11] fix: sidebar header repo/branch not syncing when switching tabs Cherry-picked from upstream d2341d50 --- src/renderer/store/slices/tabSlice.ts | 2 +- test/renderer/store/tabSlice.test.ts | 72 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index 7a4be286..4fc1e8c4 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -292,7 +292,7 @@ export const createTabSlice: StateCreator = (set, ge for (const repo of state.repositoryGroups) { for (const wt of repo.worktrees) { - if (wt.sessions.includes(sessionId)) { + if (wt.id === projectId) { foundRepo = repo.id; foundWorktree = wt.id; break; diff --git a/test/renderer/store/tabSlice.test.ts b/test/renderer/store/tabSlice.test.ts index 582006c3..b1090044 100644 --- a/test/renderer/store/tabSlice.test.ts +++ b/test/renderer/store/tabSlice.test.ts @@ -234,6 +234,78 @@ describe('tabSlice', () => { expect(store.getState().activeTabId).toBe(tab1Id); }); + it('should sync selectedRepositoryId and selectedWorktreeId when switching tabs across repos', () => { + // Setup repositoryGroups with two repos, each with one worktree + store.setState({ + repositoryGroups: [ + { + id: 'repo-A', + identity: null, + name: 'Repo A', + worktrees: [ + { + id: 'worktree-A', + path: '/path/a', + name: 'main', + isMainWorktree: true, + source: 'git', + sessions: [], + createdAt: 0, + }, + ], + totalSessions: 0, + }, + { + id: 'repo-B', + identity: null, + name: 'Repo B', + worktrees: [ + { + id: 'worktree-B', + path: '/path/b', + name: 'develop', + isMainWorktree: true, + source: 'git', + sessions: [], + createdAt: 0, + }, + ], + totalSessions: 0, + }, + ] as never[], + selectedRepositoryId: 'repo-A', + selectedWorktreeId: 'worktree-A', + }); + + // Open tab from repo A + store.getState().openTab({ + type: 'session', + sessionId: 'session-A', + projectId: 'worktree-A', + label: 'Session A', + }); + const tabAId = store.getState().activeTabId; + + // Open tab from repo B + store.getState().openTab({ + type: 'session', + sessionId: 'session-B', + projectId: 'worktree-B', + label: 'Session B', + }); + + // Switch back to tab A + store.getState().setActiveTab(tabAId!); + expect(store.getState().selectedRepositoryId).toBe('repo-A'); + expect(store.getState().selectedWorktreeId).toBe('worktree-A'); + + // Switch to tab B + const tabBId = store.getState().openTabs.find((t) => t.sessionId === 'session-B')?.id; + store.getState().setActiveTab(tabBId!); + expect(store.getState().selectedRepositoryId).toBe('repo-B'); + expect(store.getState().selectedWorktreeId).toBe('worktree-B'); + }); + it('should preserve sidebar state for non-session tabs', () => { // Setup initial state with projects data so setActiveTab can find the project store.setState({ From aaa766b2ba48b0e75d1c944f4ac56ac6feb12130 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:59:03 +0200 Subject: [PATCH 03/11] perf: replace 8 filter passes with single-pass message categorization Cherry-picked from upstream 9fa2590e --- src/main/services/parsing/SessionParser.ts | 45 ++++++++++++++++------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/main/services/parsing/SessionParser.ts b/src/main/services/parsing/SessionParser.ts index 093dcaaf..fc5c85bd 100644 --- a/src/main/services/parsing/SessionParser.ts +++ b/src/main/services/parsing/SessionParser.ts @@ -85,21 +85,42 @@ export class SessionParser { * Process parsed messages into structured data. */ private processMessages(messages: ParsedMessage[]): ParsedSession { - // Group by type + // Single-pass categorization instead of 8 separate filter passes const byType = { - user: messages.filter((m) => m.type === 'user'), - realUser: messages.filter(isParsedRealUserMessage), - internalUser: messages.filter(isParsedInternalUserMessage), - assistant: messages.filter((m) => m.type === 'assistant'), - system: messages.filter((m) => m.type === 'system'), - other: messages.filter( - (m) => m.type !== 'user' && m.type !== 'assistant' && m.type !== 'system' - ), + user: [] as ParsedMessage[], + realUser: [] as ParsedMessage[], + internalUser: [] as ParsedMessage[], + assistant: [] as ParsedMessage[], + system: [] as ParsedMessage[], + other: [] as ParsedMessage[], }; + const sidechainMessages: ParsedMessage[] = []; + const mainMessages: ParsedMessage[] = []; - // Separate sidechain and main messages - const sidechainMessages = messages.filter((m) => m.isSidechain); - const mainMessages = messages.filter((m) => !m.isSidechain); + for (const m of messages) { + switch (m.type) { + case 'user': + byType.user.push(m); + if (isParsedRealUserMessage(m)) byType.realUser.push(m); + if (isParsedInternalUserMessage(m)) byType.internalUser.push(m); + break; + case 'assistant': + byType.assistant.push(m); + break; + case 'system': + byType.system.push(m); + break; + default: + byType.other.push(m); + break; + } + + if (m.isSidechain) { + sidechainMessages.push(m); + } else { + mainMessages.push(m); + } + } // Calculate metrics const metrics = calculateMetrics(messages); From 81921abfd49844d17243e0b979ade7c14140829d Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:59:17 +0200 Subject: [PATCH 04/11] fix: increase global test timeout for CI environments Cherry-picked from upstream ad25f0f5 --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index 902129c6..6d1ad570 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ test: { globals: true, environment: 'happy-dom', + testTimeout: 15000, setupFiles: ['./test/setup.ts'], include: ['test/**/*.test.ts'], coverage: { From 86d69476c12ee8752bdf545a9c38ba8d23226001 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:59:27 +0200 Subject: [PATCH 05/11] fix: add retry to temp dir cleanup for Windows CI Cherry-picked from upstream c5d33727 --- test/main/services/discovery/SessionSearcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/main/services/discovery/SessionSearcher.test.ts b/test/main/services/discovery/SessionSearcher.test.ts index fa3dc845..5f18765f 100644 --- a/test/main/services/discovery/SessionSearcher.test.ts +++ b/test/main/services/discovery/SessionSearcher.test.ts @@ -10,7 +10,7 @@ describe('SessionSearcher', () => { afterEach(() => { for (const dir of tempDirs) { - fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } tempDirs.length = 0; }); From e6fa5c6f0698c97667584509778bde28df67cad3 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:59:43 +0200 Subject: [PATCH 06/11] docs: enhance CONTRIBUTING.md with project philosophy Cherry-picked from upstream d655dddb --- .github/CONTRIBUTING.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4ce39e36..cf3195c2 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,6 +2,23 @@ Thanks for contributing to Claude Agent Teams UI. +## Project Philosophy & Scope + +claude-devtools exists to make the invisible parts of Claude Code visible — the token flows, context injections, tool executions, and session dynamics that are otherwise hidden behind the CLI. It is not a general-purpose dashboard or an IDE. + +Our priorities: + +1. **Parity with Claude Code** — When Claude Code ships new capabilities (agent teams, context tracking, new tool types), we adopt them quickly so users always have full visibility. +2. **Context engineering insight** — Features that help users understand *what* is consuming their context window, *how* tokens flow through a session, and *where* to optimize. If it doesn't help someone make better decisions about their Claude Code usage, it probably doesn't belong here. +3. **Stability over novelty** — A reliable, fast tool for professional workflows. We'd rather do fewer things well than many things poorly. + +**What we generally do not accept:** +- Large custom features that don't directly serve context visibility or Claude Code parity. +- Speculative features that add maintenance burden without solving a concrete problem users face today. +- PRs that significantly expand scope without prior discussion in an Issue. + +If you're considering a non-trivial contribution, **open an Issue first** to check alignment with the current roadmap. This saves everyone time and keeps the project focused. + ## Prerequisites - Node.js 20+ - pnpm 10+ From b87082a915fec25793be2e6ebb71e41077660dba Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:05:01 +0200 Subject: [PATCH 07/11] fix: requestId dedup to prevent ~2x cost overcounting + stale session detection Manually ported from upstream: - ef2e0868: deduplicate streaming JSONL entries by requestId - 4f21f267: mark stale ongoing sessions as dead after 5min inactivity --- src/main/services/discovery/ProjectScanner.ts | 8 +++- src/main/types/messages.ts | 2 + src/main/utils/jsonl.ts | 44 ++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 3227604d..15bc62f7 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -984,6 +984,12 @@ export class ProjectScanner { ? firstMessageTimestampMs : birthtimeMs; + // If messages suggest ongoing but the file hasn't been written to in 5+ minutes, + // the session likely crashed/was killed (upstream fix #94) + const STALE_SESSION_THRESHOLD_MS = 5 * 60 * 1000; + const isOngoing = + metadata.isOngoing && Date.now() - effectiveMtime < STALE_SESSION_THRESHOLD_MS; + return { id: sessionId, projectId, @@ -993,7 +999,7 @@ export class ProjectScanner { messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents, messageCount: metadata.messageCount, - isOngoing: metadata.isOngoing, + isOngoing, gitBranch: metadata.gitBranch ?? undefined, metadataLevel, contextConsumption: metadata.contextConsumption, diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index 7a8c537b..79f6652e 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -103,6 +103,8 @@ export interface ParsedMessage { toolUseResult?: ToolUseResultData; /** Whether this is a compact summary boundary message */ isCompactSummary?: boolean; + /** API request ID for deduplicating streaming entries */ + requestId?: string; } // ============================================================================= diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 653f7755..59fe25d5 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -125,6 +125,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let role: string | undefined; let usage: TokenUsage | undefined; let model: string | undefined; + let requestId: string | undefined; let cwd: string | undefined; let gitBranch: string | undefined; let agentId: string | undefined; @@ -163,6 +164,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { usage = entry.message.usage; model = entry.message.model; agentId = entry.agentId; + requestId = entry.requestId; } else if (entry.type === 'system') { isMeta = entry.isMeta ?? false; } @@ -195,6 +197,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { sourceToolUseID, sourceToolAssistantUUID, toolUseResult, + requestId, }; } @@ -221,24 +224,61 @@ function parseMessageType(type?: string): MessageType | null { } } +// ============================================================================= +// Streaming Deduplication +// ============================================================================= + +/** + * Deduplicate streaming assistant entries by requestId. + * + * Claude Code writes multiple JSONL entries per API response during streaming, + * each with the same requestId but incrementally increasing output_tokens. + * Only the last entry per requestId has the final, complete token counts. + * + * Messages without a requestId (user, system, etc.) pass through unchanged. + * Returns a new array with only the last entry per requestId kept. + */ +export function deduplicateByRequestId(messages: ParsedMessage[]): ParsedMessage[] { + const lastIndexByRequestId = new Map(); + for (let i = 0; i < messages.length; i++) { + const rid = messages[i].requestId; + if (rid) { + lastIndexByRequestId.set(rid, i); + } + } + + if (lastIndexByRequestId.size === 0) { + return messages; + } + + return messages.filter((msg, i) => { + if (!msg.requestId) return true; + return lastIndexByRequestId.get(msg.requestId) === i; + }); +} + // ============================================================================= // Metrics Calculation // ============================================================================= /** * Calculate session metrics from parsed messages. + * Deduplicates streaming entries by requestId before summing to avoid ~2x cost overcounting. */ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { if (messages.length === 0) { return { ...EMPTY_METRICS }; } + // Deduplicate streaming entries: keep only the last entry per requestId + const dedupedMessages = deduplicateByRequestId(messages); + let inputTokens = 0; let outputTokens = 0; let cacheReadTokens = 0; let cacheCreationTokens = 0; - // Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions) + // Get timestamps for duration from ALL messages (not deduped) for accurate session length const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t)); let minTime = 0; @@ -255,7 +295,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { // Calculate cost per-message, then sum (tiered pricing applies per-API-call, not to aggregated totals) let costUsd = 0; - for (const msg of messages) { + for (const msg of dedupedMessages) { if (msg.usage) { const msgInputTokens = msg.usage.input_tokens ?? 0; const msgOutputTokens = msg.usage.output_tokens ?? 0; From aeed5a087324c27084820075b53b381812a46594 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:05:53 +0200 Subject: [PATCH 08/11] feat: add syntax highlighting for R, Ruby, PHP, and SQL Cherry-picked from upstream 022c75da with conflict resolution. Merged our codeClassName support with upstream per-line highlighting. --- .../chat/viewers/MarkdownViewer.tsx | 12 +- .../chat/viewers/syntaxHighlighter.ts | 216 +++++++++++++++++- 2 files changed, 224 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 703c07d9..00c5da3f 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -39,6 +39,7 @@ import { highlightSearchInChildren, type SearchContext, } from '../searchHighlightUtils'; +import { highlightLine } from '../viewers/syntaxHighlighter'; import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; @@ -535,12 +536,21 @@ function createViewerMarkdownComponents( const isBlock = (hasLanguage ?? false) || isMultiLine; if (isBlock) { + const lang = codeClassName?.replace('language-', '') ?? ''; + const raw = typeof children === 'string' ? children : ''; + const text = raw.replace(/\n$/, ''); + const lines = text.split('\n'); return ( - {hl(children)} + {lines.map((line, i) => ( + + {hl(highlightLine(line, lang))} + {i < lines.length - 1 ? '\n' : null} + + ))} ); } diff --git a/src/renderer/components/chat/viewers/syntaxHighlighter.ts b/src/renderer/components/chat/viewers/syntaxHighlighter.ts index 2271d08b..e6e2f6b3 100644 --- a/src/renderer/components/chat/viewers/syntaxHighlighter.ts +++ b/src/renderer/components/chat/viewers/syntaxHighlighter.ts @@ -208,6 +208,200 @@ const KEYWORDS: Record> = { 'true', 'false', ]), + r: new Set([ + 'if', + 'else', + 'for', + 'while', + 'repeat', + 'function', + 'return', + 'next', + 'break', + 'in', + 'library', + 'require', + 'source', + 'TRUE', + 'FALSE', + 'NULL', + 'NA', + 'Inf', + 'NaN', + 'NA_integer_', + 'NA_real_', + 'NA_complex_', + 'NA_character_', + ]), + ruby: new Set([ + 'def', + 'class', + 'module', + 'end', + 'do', + 'if', + 'elsif', + 'else', + 'unless', + 'while', + 'until', + 'for', + 'in', + 'begin', + 'rescue', + 'ensure', + 'raise', + 'return', + 'yield', + 'block_given?', + 'require', + 'require_relative', + 'include', + 'extend', + 'attr_accessor', + 'attr_reader', + 'attr_writer', + 'self', + 'super', + 'nil', + 'true', + 'false', + 'and', + 'or', + 'not', + 'then', + 'when', + 'case', + 'lambda', + 'proc', + 'puts', + 'print', + ]), + php: new Set([ + 'function', + 'class', + 'interface', + 'trait', + 'extends', + 'implements', + 'namespace', + 'use', + 'public', + 'private', + 'protected', + 'static', + 'abstract', + 'final', + 'const', + 'var', + 'new', + 'return', + 'if', + 'elseif', + 'else', + 'for', + 'foreach', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'default', + 'try', + 'catch', + 'finally', + 'throw', + 'as', + 'echo', + 'print', + 'require', + 'require_once', + 'include', + 'include_once', + 'true', + 'false', + 'null', + 'array', + 'isset', + 'unset', + 'empty', + 'self', + 'this', + ]), + sql: new Set([ + 'SELECT', + 'FROM', + 'WHERE', + 'INSERT', + 'INTO', + 'UPDATE', + 'SET', + 'DELETE', + 'CREATE', + 'ALTER', + 'DROP', + 'TABLE', + 'INDEX', + 'VIEW', + 'DATABASE', + 'JOIN', + 'INNER', + 'LEFT', + 'RIGHT', + 'OUTER', + 'FULL', + 'CROSS', + 'ON', + 'AND', + 'OR', + 'NOT', + 'IN', + 'EXISTS', + 'BETWEEN', + 'LIKE', + 'IS', + 'NULL', + 'AS', + 'ORDER', + 'BY', + 'GROUP', + 'HAVING', + 'LIMIT', + 'OFFSET', + 'UNION', + 'ALL', + 'DISTINCT', + 'COUNT', + 'SUM', + 'AVG', + 'MIN', + 'MAX', + 'CASE', + 'WHEN', + 'THEN', + 'ELSE', + 'END', + 'BEGIN', + 'COMMIT', + 'ROLLBACK', + 'TRANSACTION', + 'PRIMARY', + 'KEY', + 'FOREIGN', + 'REFERENCES', + 'CONSTRAINT', + 'DEFAULT', + 'VALUES', + 'TRUE', + 'FALSE', + 'INTEGER', + 'VARCHAR', + 'TEXT', + 'BOOLEAN', + 'DATE', + 'TIMESTAMP', + ]), }; // Extend tsx/jsx to use typescript/javascript keywords @@ -296,8 +490,23 @@ export function highlightLine(line: string, language: string): React.ReactNode[] break; } - // Check for comment (# style for Python/Shell) - if ((language === 'python' || language === 'bash') && remaining.startsWith('#')) { + // Check for comment (# style for Python/Shell/R/Ruby/PHP) + if ( + (language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') && + remaining.startsWith('#') + ) { + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-comment)', fontStyle: 'italic' } }, + remaining + ) + ); + break; + } + + // Check for comment (-- style for SQL) + if (language === 'sql' && remaining.startsWith('--')) { segments.push( React.createElement( 'span', @@ -326,7 +535,8 @@ export function highlightLine(line: string, language: string): React.ReactNode[] const wordMatch = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(remaining); if (wordMatch) { const word = wordMatch[1]; - if (keywords.has(word)) { + // SQL keywords are case-insensitive + if (keywords.has(word) || (language === 'sql' && keywords.has(word.toUpperCase()))) { segments.push( React.createElement( 'span', From e30f6482bf32a5620f9790d687e55e55101621e7 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:22:04 +0200 Subject: [PATCH 09/11] fix: WSL mount path translation + Windows drive letter normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manually ported from upstream: - c239cda6: translateWslMountPath() converts /mnt/X/... → X:/... on Windows - 7f5fbdab: normalizeDriveLetter() uppercases drive letter (c:/ → C:/) Both are no-op on macOS/Linux. Fixes session fragmentation on Windows+WSL. --- src/main/utils/metadataExtraction.ts | 15 ++++++++++++++- src/main/utils/pathDecoder.ts | 22 +++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index f861401e..f28146ff 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -9,11 +9,24 @@ import * as readline from 'readline'; import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types'; +import { translateWslMountPath } from './pathDecoder'; + import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; import type { Readable } from 'stream'; const logger = createLogger('Util:metadataExtraction'); +/** + * Normalize Windows drive letter to uppercase for consistent path comparison. + * CLI uses uppercase (C:\...) while VS Code extension uses lowercase (c:\...). + */ +function normalizeDriveLetter(p: string): string { + if (p.length >= 2 && p[1] === ':') { + return p[0].toUpperCase() + p.slice(1); + } + return p; +} + const defaultProvider = new LocalFileSystemProvider(); const JSONL_HEAD_TIMEOUT_MS = 2000; @@ -100,7 +113,7 @@ export async function extractCwd( } // Only conversational entries have cwd if ('cwd' in entry && entry.cwd) { - return entry.cwd; + return normalizeDriveLetter(translateWslMountPath(entry.cwd)); } } } catch (error) { diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 5b04b068..4590181c 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -69,7 +69,10 @@ export function decodePath(encodedName: string): string { } // Ensure leading slash for POSIX-style absolute paths - return decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`; + const absolutePath = decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`; + + // Translate WSL mount paths to Windows drive-letter paths on Windows + return translateWslMountPath(absolutePath); } /** @@ -91,6 +94,23 @@ export function extractProjectName(encodedName: string, cwdHint?: string): strin return segments[segments.length - 1] || encodedName; } +/** + * Translate WSL mount paths (/mnt/X/...) to Windows drive-letter paths (X:/...) + * when running on Windows. No-op on other platforms. + */ +export function translateWslMountPath(posixPath: string): string { + if (process.platform !== 'win32') { + return posixPath; + } + const match = /^\/mnt\/([a-zA-Z])(\/.*)?$/.exec(posixPath); + if (match) { + const drive = match[1].toUpperCase(); + const rest = match[2] ?? ''; + return `${drive}:${rest}`; + } + return posixPath; +} + // ============================================================================= // Validation // ============================================================================= From c21350713c3d047be10277f167876d0ce416265e Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:32:37 +0200 Subject: [PATCH 10/11] perf: replace remark-based search with plain text indexOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main/services/discovery/ProjectScanner.ts | 21 ++++++- .../services/discovery/SearchTextCache.ts | 2 +- .../services/discovery/SessionSearcher.ts | 54 ++++++++--------- .../components/chat/LastOutputDisplay.tsx | 16 +++-- .../components/chat/UserChatGroup.tsx | 16 +++-- .../components/chat/searchHighlightUtils.ts | 3 + .../chat/viewers/MarkdownViewer.tsx | 19 +++--- .../store/slices/conversationSlice.ts | 58 ++++++++++++++----- src/shared/utils/markdownTextSearch.ts | 8 ++- .../discovery/SessionSearcher.test.ts | 8 +-- 10 files changed, 128 insertions(+), 77 deletions(-) diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 15bc62f7..0b3ac09b 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -71,6 +71,9 @@ import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemP 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. // 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 @@ -147,6 +150,9 @@ export class ProjectScanner { private scanCache: { projects: Project[]; timestamp: number } | null = null; 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 private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64; 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 }; } - // Get all projects - const projects = await this.scan(); + // Use cached project list to avoid re-scanning disk on every keystroke + 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) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; @@ -1338,7 +1353,7 @@ export class ProjectScanner { // Search across all projects with bounded concurrency 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) { const batch = projects.slice(i, i + searchBatchSize); diff --git a/src/main/services/discovery/SearchTextCache.ts b/src/main/services/discovery/SearchTextCache.ts index 68c61ba9..e48916bd 100644 --- a/src/main/services/discovery/SearchTextCache.ts +++ b/src/main/services/discovery/SearchTextCache.ts @@ -21,7 +21,7 @@ export class SearchTextCache { private readonly cache = new Map(); private readonly maxSize: number; - constructor(maxSize: number = 200) { + constructor(maxSize: number = 1000) { this.maxSize = maxSize; } diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index 5f0615f1..0d1b1857 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -15,10 +15,6 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFile import { parseJsonlFile } from '@main/utils/jsonl'; import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; -import { - extractMarkdownPlainText, - findMarkdownSearchMatches, -} from '@shared/utils/markdownTextSearch'; import * as path from 'path'; import { startMainSpan } from '../../sentry'; @@ -112,7 +108,7 @@ export class SessionSearcher { sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); // 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 ? this.buildFastSearchStageBoundaries(sessionFiles.length) : [sessionFiles.length]; @@ -236,6 +232,10 @@ export class SessionSearcher { 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) { if (results.length >= maxResults) break; @@ -262,31 +262,20 @@ export class SessionSearcher { sessionId: string, sessionTitle?: string ): void { - const mdMatches = findMarkdownSearchMatches(entry.text, query); - if (mdMatches.length === 0) return; + // Plain indexOf search — no markdown/remark parsing + const lowerText = entry.text.toLowerCase(); + if (!lowerText.includes(query)) return; - // Build plain text once for context snippet extraction - const plainText = extractMarkdownPlainText(entry.text); - const lowerPlain = plainText.toLowerCase(); - - for (const mdMatch of mdMatches) { + // Use raw text directly for context snippets + let pos = 0; + let matchIndex = 0; + while ((pos = lowerText.indexOf(query, pos)) !== -1) { if (results.length >= maxResults) return; - // Find approximate position in plain text for context extraction - let pos = 0; - for (let i = 0; i < mdMatch.matchIndexInItem; i++) { - const idx = lowerPlain.indexOf(query, pos); - 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; + const contextStart = Math.max(0, pos - 50); + const contextEnd = Math.min(entry.text.length, pos + query.length + 50); + const context = entry.text.slice(contextStart, contextEnd); + const matchedText = entry.text.slice(pos, pos + query.length); results.push({ sessionId, @@ -294,15 +283,20 @@ export class SessionSearcher { sessionTitle: sessionTitle ?? 'Untitled Session', matchedText, context: - (contextStart > 0 ? '...' : '') + context + (contextEnd < plainText.length ? '...' : ''), + (contextStart > 0 ? '...' : '') + + context + + (contextEnd < entry.text.length ? '...' : ''), messageType: entry.messageType, timestamp: entry.timestamp, groupId: entry.groupId, itemType: entry.itemType, - matchIndexInItem: mdMatch.matchIndexInItem, - matchStartOffset: effectivePos, + matchIndexInItem: matchIndex, + matchStartOffset: pos, messageUuid: entry.messageUuid, }); + + matchIndex++; + pos += query.length; } } diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx index d4b25355..db710805 100644 --- a/src/renderer/components/chat/LastOutputDisplay.tsx +++ b/src/renderer/components/chat/LastOutputDisplay.tsx @@ -11,7 +11,7 @@ import { CopyButton } from '../common/CopyButton'; import { OngoingBanner } from '../common/OngoingIndicator'; import { createMarkdownComponents, markdownComponents } from './markdownComponents'; -import { createSearchContext } from './searchHighlightUtils'; +import { createSearchContext, EMPTY_SEARCH_MATCHES } from './searchHighlightUtils'; import type { AIGroupLastOutput } from '@renderer/types/groups'; @@ -41,12 +41,16 @@ export const LastOutputDisplay = ({ isLastGroup = false, isSessionOngoing = false, }: Readonly): React.JSX.Element | null => { + // Only re-render if THIS AI group has search matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: s.searchQuery, - searchMatches: s.searchMatches, - currentSearchIndex: s.currentSearchIndex, - })) + useShallow((s) => { + const hasMatch = s.searchMatchItemIds.has(aiGroupId); + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); const isTextOutput = lastOutput?.type === 'text' && Boolean(lastOutput.text); diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 6cf59051..860a5dc3 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -21,6 +21,7 @@ import { CopyButton } from '../common/CopyButton'; import { createSearchContext, + EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from './searchHighlightUtils'; @@ -401,13 +402,16 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. [teams] ); - // Get search state for highlighting + // Get search state for highlighting — only re-render if THIS item has matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: s.searchQuery, - searchMatches: s.searchMatches, - currentSearchIndex: s.currentSearchIndex, - })) + useShallow((s) => { + const hasMatch = s.searchMatchItemIds.has(groupId); + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); const hasImages = content.images.length > 0; diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts index 34684f96..39a15be5 100644 --- a/src/renderer/components/chat/searchHighlightUtils.ts +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -8,6 +8,9 @@ import React from 'react'; 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 const baseStyles: React.CSSProperties = { borderRadius: '0.125rem', diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 00c5da3f..9636ff2c 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -36,6 +36,7 @@ import { useShallow } from 'zustand/react/shallow'; import { createSearchContext, + EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from '../searchHighlightUtils'; @@ -44,8 +45,6 @@ import { highlightLine } from '../viewers/syntaxHighlighter'; import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; -import type { SearchMatch } from '@renderer/store/types'; - // ============================================================================= // Types // ============================================================================= @@ -73,7 +72,6 @@ interface MarkdownViewerProps { const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); -const EMPTY_SEARCH_MATCHES: SearchMatch[] = []; const NOOP_TEAM_CLICK = (): void => undefined; // ============================================================================= @@ -714,13 +712,16 @@ export const MarkdownViewer: React.FC = ({ const isTooLarge = content.length > MAX_MARKDOWN_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( - useShallow((s) => ({ - searchQuery: itemId ? s.searchQuery : '', - searchMatches: itemId ? s.searchMatches : EMPTY_SEARCH_MATCHES, - currentSearchIndex: itemId ? s.currentSearchIndex : -1, - })) + useShallow((s) => { + const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; + return { + 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). diff --git a/src/renderer/store/slices/conversationSlice.ts b/src/renderer/store/slices/conversationSlice.ts index ec40e5db..d6ca21ac 100644 --- a/src/renderer/store/slices/conversationSlice.ts +++ b/src/renderer/store/slices/conversationSlice.ts @@ -4,7 +4,6 @@ import { findLastOutput } from '@renderer/utils/aiGroupEnhancer'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; -import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch'; import type { AppState, SearchMatch } from '../types'; import type { AIGroupExpansionLevel } from '@renderer/types/groups'; @@ -17,6 +16,9 @@ import type { StateCreator } from 'zustand'; 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 => { if (typeof window === 'undefined') return false; try { @@ -57,6 +59,10 @@ export interface ConversationSlice { searchResultCount: number; currentSearchIndex: number; 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; // Auto-expand state for search results /** AI group IDs that should be expanded to show search results */ @@ -126,6 +132,8 @@ export const createConversationSlice: StateCreator { - const mdMatches = findMarkdownSearchMatches(text, lowerQuery); - for (const mdMatch of mdMatches) { + if (capped) return; + 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({ itemId, itemType, - matchIndexInItem: mdMatch.matchIndexInItem, + matchIndexInItem, globalIndex, displayItemId, }); + matchIndexInItem++; globalIndex++; + pos += lowerQuery.length; } }; for (const item of conversation.items) { + if (capped) break; if (item.type === 'user') { const raw = item.group.content.rawText ?? item.group.content.text ?? ''; const text = stripAgentBlocks(raw); - addMarkdownMatches(text, item.group.id, 'user'); + addPlainTextMatches(text, item.group.id, 'user'); } else if (item.type === 'ai') { - // For AI items: ONLY search lastOutput text (not tool results, thinking, or subagents) const aiGroup = item.group; const itemId = aiGroup.id; const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false); if (lastOutput?.type === 'text' && lastOutput.text) { - // Last output text - displayItemId indicates this is lastOutput content - addMarkdownMatches(lastOutput.text, itemId, 'ai', 'lastOutput'); + addPlainTextMatches(lastOutput.text, itemId, 'ai', 'lastOutput'); } - // Skip tool_result type - only searching text output } - // Skip system items entirely } if (isSearchDebugEnabled()) { @@ -293,11 +309,19 @@ export const createConversationSlice: StateCreator(); + for (const match of matches) { + matchItemIds.add(match.itemId); + } + set({ searchQuery: query, searchResultCount: matches.length, currentSearchIndex: matches.length > 0 ? 0 : -1, searchMatches: matches, + searchResultsCapped: capped, + searchMatchItemIds: matchItemIds, }); }, @@ -406,6 +430,8 @@ export const createConversationSlice: StateCreator(); function getCachedSegments(markdown: string): string[] { @@ -154,6 +154,9 @@ export interface MarkdownSearchMatch { export function findMarkdownSearchMatches(markdown: string, query: string): MarkdownSearchMatch[] { 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 lowerQuery = query.toLowerCase(); const matches: MarkdownSearchMatch[] = []; @@ -178,6 +181,9 @@ export function findMarkdownSearchMatches(markdown: string, query: string): Mark export function countMarkdownSearchMatches(markdown: string, query: string): number { 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 lowerQuery = query.toLowerCase(); let count = 0; diff --git a/test/main/services/discovery/SessionSearcher.test.ts b/test/main/services/discovery/SessionSearcher.test.ts index 5f18765f..385fa83c 100644 --- a/test/main/services/discovery/SessionSearcher.test.ts +++ b/test/main/services/discovery/SessionSearcher.test.ts @@ -76,7 +76,7 @@ describe('SessionSearcher', () => { ).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-')); tempDirs.push(projectsDir); @@ -110,13 +110,11 @@ describe('SessionSearcher', () => { const searcher = new SessionSearcher(projectsDir); const result = await searcher.searchSessions(projectId, 'tsx', 50); - // "tsx" should match in user text ("Show me tsx code") but NOT in the - // code fence language identifier (```tsx). It should also not match in - // the code block content since "const x = 1;" doesn't contain "tsx". + // Plain text search: "tsx" matches in user text AND in the code fence identifier const userResults = result.results.filter((r) => r.itemType === 'user'); const aiResults = result.results.filter((r) => r.itemType === 'ai'); expect(userResults).toHaveLength(1); - expect(aiResults).toHaveLength(0); + expect(aiResults).toHaveLength(1); }); }); From 22387ca0cbfab4dcd26e3a1faa8d7df62cf331c3 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:34:22 +0200 Subject: [PATCH 11/11] perf: add search debounce and increase global search timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchBar: 300ms debounce with local input state, flush on Enter - CommandPalette: global search debounce 200→400ms - Show "500+" when search results are capped - Fix: syncSearchMatchesWithRendered now updates searchMatchItemIds Ported from upstream 5c7f921e (SearchBar/CommandPalette parts). --- .../components/search/CommandPalette.tsx | 2 +- src/renderer/components/search/SearchBar.tsx | 61 +++++++++++++++++-- .../store/slices/conversationSlice.ts | 7 +++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 58d8ebe5..86e50668 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -288,7 +288,7 @@ export const CommandPalette = (): React.JSX.Element | null => { setLoading(false); } } - }, 200); + }, 400); return () => clearTimeout(timeoutId); }, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]); diff --git a/src/renderer/components/search/SearchBar.tsx b/src/renderer/components/search/SearchBar.tsx index d7e6b448..ab620535 100644 --- a/src/renderer/components/search/SearchBar.tsx +++ b/src/renderer/components/search/SearchBar.tsx @@ -1,14 +1,19 @@ /** * SearchBar - In-session search interface component. * Appears at the top of the chat view when Cmd+F is pressed. + * + * Uses a local input state with debouncing to avoid triggering expensive + * search on every keystroke. */ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { ChevronDown, ChevronUp, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +const SEARCH_DEBOUNCE_MS = 300; + interface SearchBarProps { tabId?: string; } @@ -19,6 +24,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = searchVisible, searchResultCount, currentSearchIndex, + searchResultsCapped, conversation, setSearchQuery, hideSearch, @@ -30,6 +36,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = searchVisible: s.searchVisible, searchResultCount: s.searchResultCount, currentSearchIndex: s.currentSearchIndex, + searchResultsCapped: s.searchResultsCapped, conversation: tabId ? (s.tabSessionData[tabId]?.conversation ?? s.conversation) : s.conversation, @@ -40,8 +47,43 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = })) ); + // Local input value for responsive typing — debounced before triggering search + const [localQuery, setLocalQuery] = useState(searchQuery); + const debounceRef = useRef>( + 0 as unknown as ReturnType + ); + const inputRef = useRef(null); + // Sync local state when store query changes externally (e.g., hideSearch clears it) + useEffect(() => { + setLocalQuery(searchQuery); + }, [searchQuery]); + + // Debounced search dispatch + const handleChange = useCallback( + (value: string) => { + setLocalQuery(value); + clearTimeout(debounceRef.current); + + // Clear immediately when input is emptied + if (!value.trim()) { + setSearchQuery('', conversation); + return; + } + + debounceRef.current = setTimeout(() => { + setSearchQuery(value, conversation); + }, SEARCH_DEBOUNCE_MS); + }, + [conversation, setSearchQuery] + ); + + // Cleanup timeout on unmount + useEffect(() => { + return () => clearTimeout(debounceRef.current); + }, []); + // Auto-focus input when search becomes visible useEffect(() => { if (searchVisible && inputRef.current) { @@ -55,6 +97,11 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = if (e.key === 'Escape') { hideSearch(); } else if (e.key === 'Enter') { + // Flush any pending debounce immediately on Enter + clearTimeout(debounceRef.current); + if (localQuery !== searchQuery) { + setSearchQuery(localQuery, conversation); + } if (e.shiftKey) { previousSearchResult(); } else { @@ -67,14 +114,18 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = return null; } + const resultLabel = searchResultsCapped + ? `${currentSearchIndex + 1} of ${searchResultCount}+` + : `${currentSearchIndex + 1} of ${searchResultCount}`; + return (
{/* Search input */} setSearchQuery(e.target.value, conversation)} + value={localQuery} + onChange={(e) => handleChange(e.target.value)} onKeyDown={handleKeyDown} placeholder="Find in conversation..." className="w-48 rounded border border-border bg-surface-raised px-3 py-1.5 text-sm text-text focus:border-text-secondary focus:outline-none" @@ -83,9 +134,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = {/* Result count */} {searchQuery && ( - {searchResultCount > 0 - ? `${currentSearchIndex + 1} of ${searchResultCount}` - : 'No results'} + {searchResultCount > 0 ? resultLabel : 'No results'} )} diff --git a/src/renderer/store/slices/conversationSlice.ts b/src/renderer/store/slices/conversationSlice.ts index d6ca21ac..218fcae9 100644 --- a/src/renderer/store/slices/conversationSlice.ts +++ b/src/renderer/store/slices/conversationSlice.ts @@ -397,10 +397,17 @@ export const createConversationSlice: StateCreator(); + for (const match of nextMatches) { + syncedMatchItemIds.add(match.itemId); + } + set({ searchMatches: nextMatches, searchResultCount: nextMatches.length, currentSearchIndex: newCurrentIndex, + searchMatchItemIds: syncedMatchItemIds, }); },