Merge branch 'worktree-upstream-merge' into dev
This commit is contained in:
commit
c7ee51884d
28 changed files with 776 additions and 141 deletions
17
.github/CONTRIBUTING.md
vendored
17
.github/CONTRIBUTING.md
vendored
|
|
@ -2,6 +2,23 @@
|
||||||
|
|
||||||
Thanks for contributing to Claude Agent Teams UI.
|
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
|
## Prerequisites
|
||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- pnpm 10+
|
- pnpm 10+
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -984,6 +990,12 @@ export class ProjectScanner {
|
||||||
? firstMessageTimestampMs
|
? firstMessageTimestampMs
|
||||||
: birthtimeMs;
|
: 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 {
|
return {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -993,7 +1005,7 @@ export class ProjectScanner {
|
||||||
messageTimestamp: metadata.firstUserMessage?.timestamp,
|
messageTimestamp: metadata.firstUserMessage?.timestamp,
|
||||||
hasSubagents,
|
hasSubagents,
|
||||||
messageCount: metadata.messageCount,
|
messageCount: metadata.messageCount,
|
||||||
isOngoing: metadata.isOngoing,
|
isOngoing,
|
||||||
gitBranch: metadata.gitBranch ?? undefined,
|
gitBranch: metadata.gitBranch ?? undefined,
|
||||||
metadataLevel,
|
metadataLevel,
|
||||||
contextConsumption: metadata.contextConsumption,
|
contextConsumption: metadata.contextConsumption,
|
||||||
|
|
@ -1323,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 };
|
||||||
|
|
@ -1332,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,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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,21 +85,42 @@ export class SessionParser {
|
||||||
* Process parsed messages into structured data.
|
* Process parsed messages into structured data.
|
||||||
*/
|
*/
|
||||||
private processMessages(messages: ParsedMessage[]): ParsedSession {
|
private processMessages(messages: ParsedMessage[]): ParsedSession {
|
||||||
// Group by type
|
// Single-pass categorization instead of 8 separate filter passes
|
||||||
const byType = {
|
const byType = {
|
||||||
user: messages.filter((m) => m.type === 'user'),
|
user: [] as ParsedMessage[],
|
||||||
realUser: messages.filter(isParsedRealUserMessage),
|
realUser: [] as ParsedMessage[],
|
||||||
internalUser: messages.filter(isParsedInternalUserMessage),
|
internalUser: [] as ParsedMessage[],
|
||||||
assistant: messages.filter((m) => m.type === 'assistant'),
|
assistant: [] as ParsedMessage[],
|
||||||
system: messages.filter((m) => m.type === 'system'),
|
system: [] as ParsedMessage[],
|
||||||
other: messages.filter(
|
other: [] as ParsedMessage[],
|
||||||
(m) => m.type !== 'user' && m.type !== 'assistant' && m.type !== 'system'
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
const sidechainMessages: ParsedMessage[] = [];
|
||||||
|
const mainMessages: ParsedMessage[] = [];
|
||||||
|
|
||||||
// Separate sidechain and main messages
|
for (const m of messages) {
|
||||||
const sidechainMessages = messages.filter((m) => m.isSidechain);
|
switch (m.type) {
|
||||||
const mainMessages = messages.filter((m) => !m.isSidechain);
|
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
|
// Calculate metrics
|
||||||
const metrics = calculateMetrics(messages);
|
const metrics = calculateMetrics(messages);
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ export interface ParsedMessage {
|
||||||
toolUseResult?: ToolUseResultData;
|
toolUseResult?: ToolUseResultData;
|
||||||
/** Whether this is a compact summary boundary message */
|
/** Whether this is a compact summary boundary message */
|
||||||
isCompactSummary?: boolean;
|
isCompactSummary?: boolean;
|
||||||
|
/** API request ID for deduplicating streaming entries */
|
||||||
|
requestId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
let role: string | undefined;
|
let role: string | undefined;
|
||||||
let usage: TokenUsage | undefined;
|
let usage: TokenUsage | undefined;
|
||||||
let model: string | undefined;
|
let model: string | undefined;
|
||||||
|
let requestId: string | undefined;
|
||||||
let cwd: string | undefined;
|
let cwd: string | undefined;
|
||||||
let gitBranch: string | undefined;
|
let gitBranch: string | undefined;
|
||||||
let agentId: string | undefined;
|
let agentId: string | undefined;
|
||||||
|
|
@ -163,6 +164,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
usage = entry.message.usage;
|
usage = entry.message.usage;
|
||||||
model = entry.message.model;
|
model = entry.message.model;
|
||||||
agentId = entry.agentId;
|
agentId = entry.agentId;
|
||||||
|
requestId = entry.requestId;
|
||||||
} else if (entry.type === 'system') {
|
} else if (entry.type === 'system') {
|
||||||
isMeta = entry.isMeta ?? false;
|
isMeta = entry.isMeta ?? false;
|
||||||
}
|
}
|
||||||
|
|
@ -195,6 +197,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
sourceToolUseID,
|
sourceToolUseID,
|
||||||
sourceToolAssistantUUID,
|
sourceToolAssistantUUID,
|
||||||
toolUseResult,
|
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<string, number>();
|
||||||
|
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
|
// Metrics Calculation
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate session metrics from parsed messages.
|
* Calculate session metrics from parsed messages.
|
||||||
|
* Deduplicates streaming entries by requestId before summing to avoid ~2x cost overcounting.
|
||||||
*/
|
*/
|
||||||
export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
|
export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return { ...EMPTY_METRICS };
|
return { ...EMPTY_METRICS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate streaming entries: keep only the last entry per requestId
|
||||||
|
const dedupedMessages = deduplicateByRequestId(messages);
|
||||||
|
|
||||||
let inputTokens = 0;
|
let inputTokens = 0;
|
||||||
let outputTokens = 0;
|
let outputTokens = 0;
|
||||||
let cacheReadTokens = 0;
|
let cacheReadTokens = 0;
|
||||||
let cacheCreationTokens = 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));
|
const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t));
|
||||||
|
|
||||||
let minTime = 0;
|
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)
|
// Calculate cost per-message, then sum (tiered pricing applies per-API-call, not to aggregated totals)
|
||||||
let costUsd = 0;
|
let costUsd = 0;
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of dedupedMessages) {
|
||||||
if (msg.usage) {
|
if (msg.usage) {
|
||||||
const msgInputTokens = msg.usage.input_tokens ?? 0;
|
const msgInputTokens = msg.usage.input_tokens ?? 0;
|
||||||
const msgOutputTokens = msg.usage.output_tokens ?? 0;
|
const msgOutputTokens = msg.usage.output_tokens ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,24 @@ import * as readline from 'readline';
|
||||||
import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider';
|
||||||
import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types';
|
import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types';
|
||||||
|
|
||||||
|
import { translateWslMountPath } from './pathDecoder';
|
||||||
|
|
||||||
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
|
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
|
||||||
import type { Readable } from 'stream';
|
import type { Readable } from 'stream';
|
||||||
|
|
||||||
const logger = createLogger('Util:metadataExtraction');
|
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 defaultProvider = new LocalFileSystemProvider();
|
||||||
|
|
||||||
const JSONL_HEAD_TIMEOUT_MS = 2000;
|
const JSONL_HEAD_TIMEOUT_MS = 2000;
|
||||||
|
|
@ -100,7 +113,7 @@ export async function extractCwd(
|
||||||
}
|
}
|
||||||
// Only conversational entries have cwd
|
// Only conversational entries have cwd
|
||||||
if ('cwd' in entry && entry.cwd) {
|
if ('cwd' in entry && entry.cwd) {
|
||||||
return entry.cwd;
|
return normalizeDriveLetter(translateWslMountPath(entry.cwd));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,10 @@ export function decodePath(encodedName: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure leading slash for POSIX-style absolute paths
|
// 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;
|
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
|
// Validation
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,15 +36,15 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createSearchContext,
|
createSearchContext,
|
||||||
|
EMPTY_SEARCH_MATCHES,
|
||||||
highlightSearchInChildren,
|
highlightSearchInChildren,
|
||||||
type SearchContext,
|
type SearchContext,
|
||||||
} from '../searchHighlightUtils';
|
} from '../searchHighlightUtils';
|
||||||
|
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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -72,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;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -535,12 +534,21 @@ function createViewerMarkdownComponents(
|
||||||
const isBlock = (hasLanguage ?? false) || isMultiLine;
|
const isBlock = (hasLanguage ?? false) || isMultiLine;
|
||||||
|
|
||||||
if (isBlock) {
|
if (isBlock) {
|
||||||
|
const lang = codeClassName?.replace('language-', '') ?? '';
|
||||||
|
const raw = typeof children === 'string' ? children : '';
|
||||||
|
const text = raw.replace(/\n$/, '');
|
||||||
|
const lines = text.split('\n');
|
||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
className={`font-mono text-xs ${codeClassName ?? ''}`.trim()}
|
className={`font-mono text-xs ${codeClassName ?? ''}`.trim()}
|
||||||
style={{ color: COLOR_TEXT }}
|
style={{ color: COLOR_TEXT }}
|
||||||
>
|
>
|
||||||
{hl(children)}
|
{lines.map((line, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{hl(highlightLine(line, lang))}
|
||||||
|
{i < lines.length - 1 ? '\n' : null}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -704,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).
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,200 @@ const KEYWORDS: Record<string, Set<string>> = {
|
||||||
'true',
|
'true',
|
||||||
'false',
|
'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
|
// Extend tsx/jsx to use typescript/javascript keywords
|
||||||
|
|
@ -296,8 +490,23 @@ export function highlightLine(line: string, language: string): React.ReactNode[]
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for comment (# style for Python/Shell)
|
// Check for comment (# style for Python/Shell/R/Ruby/PHP)
|
||||||
if ((language === 'python' || language === 'bash') && remaining.startsWith('#')) {
|
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(
|
segments.push(
|
||||||
React.createElement(
|
React.createElement(
|
||||||
'span',
|
'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);
|
const wordMatch = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(remaining);
|
||||||
if (wordMatch) {
|
if (wordMatch) {
|
||||||
const word = wordMatch[1];
|
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(
|
segments.push(
|
||||||
React.createElement(
|
React.createElement(
|
||||||
'span',
|
'span',
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 400);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]);
|
}, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* SearchBar - In-session search interface component.
|
* SearchBar - In-session search interface component.
|
||||||
* Appears at the top of the chat view when Cmd+F is pressed.
|
* 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 { useStore } from '@renderer/store';
|
||||||
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
tabId?: string;
|
tabId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +24,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
searchVisible,
|
searchVisible,
|
||||||
searchResultCount,
|
searchResultCount,
|
||||||
currentSearchIndex,
|
currentSearchIndex,
|
||||||
|
searchResultsCapped,
|
||||||
conversation,
|
conversation,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
hideSearch,
|
hideSearch,
|
||||||
|
|
@ -30,6 +36,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
searchVisible: s.searchVisible,
|
searchVisible: s.searchVisible,
|
||||||
searchResultCount: s.searchResultCount,
|
searchResultCount: s.searchResultCount,
|
||||||
currentSearchIndex: s.currentSearchIndex,
|
currentSearchIndex: s.currentSearchIndex,
|
||||||
|
searchResultsCapped: s.searchResultsCapped,
|
||||||
conversation: tabId
|
conversation: tabId
|
||||||
? (s.tabSessionData[tabId]?.conversation ?? s.conversation)
|
? (s.tabSessionData[tabId]?.conversation ?? s.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<ReturnType<typeof setTimeout>>(
|
||||||
|
0 as unknown as ReturnType<typeof setTimeout>
|
||||||
|
);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(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
|
// Auto-focus input when search becomes visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchVisible && inputRef.current) {
|
if (searchVisible && inputRef.current) {
|
||||||
|
|
@ -55,6 +97,11 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
hideSearch();
|
hideSearch();
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
|
// Flush any pending debounce immediately on Enter
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
if (localQuery !== searchQuery) {
|
||||||
|
setSearchQuery(localQuery, conversation);
|
||||||
|
}
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
previousSearchResult();
|
previousSearchResult();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -67,14 +114,18 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resultLabel = searchResultsCapped
|
||||||
|
? `${currentSearchIndex + 1} of ${searchResultCount}+`
|
||||||
|
: `${currentSearchIndex + 1} of ${searchResultCount}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-4 top-2 z-20 flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 shadow-lg">
|
<div className="absolute right-4 top-2 z-20 flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 shadow-lg">
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={localQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value, conversation)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Find in conversation..."
|
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"
|
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 */}
|
{/* Result count */}
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<span className="whitespace-nowrap text-xs text-text-secondary">
|
<span className="whitespace-nowrap text-xs text-text-secondary">
|
||||||
{searchResultCount > 0
|
{searchResultCount > 0 ? resultLabel : 'No results'}
|
||||||
? `${currentSearchIndex + 1} of ${searchResultCount}`
|
|
||||||
: 'No results'}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -373,10 +397,17 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild matchItemIds from synced matches
|
||||||
|
const syncedMatchItemIds = new Set<string>();
|
||||||
|
for (const match of nextMatches) {
|
||||||
|
syncedMatchItemIds.add(match.itemId);
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
searchMatches: nextMatches,
|
searchMatches: nextMatches,
|
||||||
searchResultCount: nextMatches.length,
|
searchResultCount: nextMatches.length,
|
||||||
currentSearchIndex: newCurrentIndex,
|
currentSearchIndex: newCurrentIndex,
|
||||||
|
searchMatchItemIds: syncedMatchItemIds,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -406,6 +437,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,
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
||||||
|
|
||||||
for (const repo of state.repositoryGroups) {
|
for (const repo of state.repositoryGroups) {
|
||||||
for (const wt of repo.worktrees) {
|
for (const wt of repo.worktrees) {
|
||||||
if (wt.sessions.includes(sessionId)) {
|
if (wt.id === projectId) {
|
||||||
foundRepo = repo.id;
|
foundRepo = repo.id;
|
||||||
foundWorktree = wt.id;
|
foundWorktree = wt.id;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ describe('SessionSearcher', () => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs) {
|
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;
|
tempDirs.length = 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,78 @@ describe('tabSlice', () => {
|
||||||
expect(store.getState().activeTabId).toBe(tab1Id);
|
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', () => {
|
it('should preserve sidebar state for non-session tabs', () => {
|
||||||
// Setup initial state with projects data so setActiveTab can find the project
|
// Setup initial state with projects data so setActiveTab can find the project
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
|
testTimeout: 15000,
|
||||||
setupFiles: ['./test/setup.ts'],
|
setupFiles: ['./test/setup.ts'],
|
||||||
include: ['test/**/*.test.ts'],
|
include: ['test/**/*.test.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue