added debug support package production

This commit is contained in:
Pedram Amini
2025-12-20 02:51:32 -06:00
parent c5ee280235
commit ef20df59c0
20 changed files with 458 additions and 267 deletions

View File

@@ -33,6 +33,7 @@
"start": "electron .", "start": "electron .",
"clean": "rm -rf dist release node_modules/.vite", "clean": "rm -rf dist release node_modules/.vite",
"postinstall": "electron-rebuild -f -w node-pty", "postinstall": "electron-rebuild -f -w node-pty",
"lint": "tsc -p tsconfig.lint.json",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",

View File

@@ -2133,7 +2133,7 @@ describe('Search in markdown with highlighting', () => {
}); });
}); });
it('switches to raw mode when searching in rendered markdown', async () => { it('keeps rendered markdown when searching in preview mode', async () => {
render( render(
<FilePreview <FilePreview
file={createMockFile({ file={createMockFile({
@@ -2149,6 +2149,9 @@ describe('Search in markdown with highlighting', () => {
/> />
); );
// Verify we start in preview mode (ReactMarkdown is rendered)
expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
// Open search // Open search
const container = screen.getByText('test.md').closest('[tabindex="0"]'); const container = screen.getByText('test.md').closest('[tabindex="0"]');
fireEvent.keyDown(container!, { key: 'f', metaKey: true }); fireEvent.keyDown(container!, { key: 'f', metaKey: true });
@@ -2157,14 +2160,14 @@ describe('Search in markdown with highlighting', () => {
expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
}); });
// Type search - this should trigger raw mode display with highlights // Type search
const searchInput = screen.getByPlaceholderText(/Search in file/); const searchInput = screen.getByPlaceholderText(/Search in file/);
fireEvent.change(searchInput, { target: { value: 'test' } }); fireEvent.change(searchInput, { target: { value: 'test' } });
// Wait for component to switch to highlight mode // Verify we stay in preview mode (ReactMarkdown is still rendered)
// The search highlights are applied via DOM manipulation, not by switching to raw mode
await waitFor(() => { await waitFor(() => {
// When searching in markdown, it shows raw content with highlights expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
expect(screen.getByText('1/1')).toBeInTheDocument();
}); });
}); });
}); });

View File

@@ -233,22 +233,6 @@ describe('TerminalOutput', () => {
expect(userMessageContainer).toBeInTheDocument(); expect(userMessageContainer).toBeInTheDocument();
}); });
it('shows read-only badge for read-only user messages', () => {
const logs: LogEntry[] = [
createLogEntry({ text: 'Read-only message', source: 'user', readOnly: true }),
];
const session = createDefaultSession({
tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }],
activeTabId: 'tab-1',
});
const props = createDefaultProps({ session });
render(<TerminalOutput {...props} />);
expect(screen.getByText('Read-only')).toBeInTheDocument();
});
it('shows delivered checkmark for delivered messages', () => { it('shows delivered checkmark for delivered messages', () => {
const logs: LogEntry[] = [ const logs: LogEntry[] = [
createLogEntry({ text: 'Delivered message', source: 'user', delivered: true }), createLogEntry({ text: 'Delivered message', source: 'user', delivered: true }),

View File

@@ -23,7 +23,7 @@ vi.mock('lucide-react', () => {
return new Proxy({}, { return new Proxy({}, {
get(_target, prop: string) { get(_target, prop: string) {
// Ignore internal properties // Ignore internal properties
if (prop === '__esModule' || prop === 'default' || typeof prop === 'symbol') { if (prop === '__esModule' || prop === 'default' || prop === 'then' || typeof prop === 'symbol') {
return undefined; return undefined;
} }
@@ -33,6 +33,23 @@ vi.mock('lucide-react', () => {
} }
return iconCache.get(prop); return iconCache.get(prop);
}, },
has(_target, prop: string) {
if (prop === '__esModule' || prop === 'default' || prop === 'then' || typeof prop === 'symbol') {
return false;
}
return true;
},
getOwnPropertyDescriptor(_target, prop: string) {
if (prop === '__esModule' || prop === 'default' || prop === 'then' || typeof prop === 'symbol') {
return undefined;
}
return {
configurable: true,
enumerable: true,
writable: false,
value: this.get?.(_target, prop),
};
},
}); });
}); });

View File

@@ -4,7 +4,7 @@
import { getSessionById } from '../services/storage'; import { getSessionById } from '../services/storage';
import { findPlaybookById } from '../services/playbooks'; import { findPlaybookById } from '../services/playbooks';
import { runPlaybook as executePlaybook } from '../services/batch-processor'; import { runPlaybook as executePlaybook } from '../services/batch-processor';
import { detectClaude } from '../services/agent-spawner'; import { detectClaude, detectCodex } from '../services/agent-spawner';
import { emitError } from '../output/jsonl'; import { emitError } from '../output/jsonl';
import { formatRunEvent, formatError, formatInfo, formatWarning, RunEvent } from '../output/formatter'; import { formatRunEvent, formatError, formatInfo, formatWarning, RunEvent } from '../output/formatter';
import { isSessionBusyWithCli, getCliActivityForSession } from '../../shared/cli-activity'; import { isSessionBusyWithCli, getCliActivityForSession } from '../../shared/cli-activity';
@@ -94,17 +94,6 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption
const useJson = options.json; const useJson = options.json;
try { try {
// Check if Claude is available
const claude = await detectClaude();
if (!claude.available) {
if (useJson) {
emitError('Claude Code not found. Please install claude-code CLI.', 'CLAUDE_NOT_FOUND');
} else {
console.error(formatError('Claude Code not found. Please install claude-code CLI.'));
}
process.exit(1);
}
let agentId: string; let agentId: string;
let playbook; let playbook;
@@ -125,6 +114,37 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption
const agent = getSessionById(agentId)!; const agent = getSessionById(agentId)!;
// Check if agent CLI is available
if (agent.toolType === 'codex') {
const codex = await detectCodex();
if (!codex.available) {
if (useJson) {
emitError('Codex CLI not found. Please install codex CLI.', 'CODEX_NOT_FOUND');
} else {
console.error(formatError('Codex CLI not found. Please install codex CLI.'));
}
process.exit(1);
}
} else if (agent.toolType === 'claude' || agent.toolType === 'claude-code') {
const claude = await detectClaude();
if (!claude.available) {
if (useJson) {
emitError('Claude Code not found. Please install claude-code CLI.', 'CLAUDE_NOT_FOUND');
} else {
console.error(formatError('Claude Code not found. Please install claude-code CLI.'));
}
process.exit(1);
}
} else {
const message = `Agent type "${agent.toolType}" is not supported in CLI batch mode yet.`;
if (useJson) {
emitError(message, 'AGENT_UNSUPPORTED');
} else {
console.error(formatError(message));
}
process.exit(1);
}
// Check if agent is busy (either from desktop or another CLI instance) // Check if agent is busy (either from desktop or another CLI instance)
let busyCheck = checkAgentBusy(agent.id, agent.name); let busyCheck = checkAgentBusy(agent.id, agent.name);

View File

@@ -1,10 +1,11 @@
// Agent spawner service for CLI // Agent spawner service for CLI
// Spawns Claude Code and parses its output // Spawns agent CLIs (Claude Code, Codex) and parses their output
import { spawn, SpawnOptions } from 'child_process'; import { spawn, SpawnOptions } from 'child_process';
import * as os from 'os'; import * as os from 'os';
import * as fs from 'fs'; import * as fs from 'fs';
import type { UsageStats } from '../../shared/types'; import type { ToolType, UsageStats } from '../../shared/types';
import { CodexOutputParser } from '../../main/parsers/codex-output-parser';
import { getAgentCustomPath } from './storage'; import { getAgentCustomPath } from './storage';
// Claude Code default command and arguments (same as Electron app) // Claude Code default command and arguments (same as Electron app)
@@ -14,6 +15,13 @@ const CLAUDE_ARGS = ['--print', '--verbose', '--output-format', 'stream-json', '
// Cached Claude path (resolved once at startup) // Cached Claude path (resolved once at startup)
let cachedClaudePath: string | null = null; let cachedClaudePath: string | null = null;
// Codex default command and arguments (batch mode)
const CODEX_DEFAULT_COMMAND = 'codex';
const CODEX_ARGS = ['exec', '--json', '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check'];
// Cached Codex path (resolved once at startup)
let cachedCodexPath: string | null = null;
// Result from spawning an agent // Result from spawning an agent
export interface AgentResult { export interface AgentResult {
success: boolean; success: boolean;
@@ -117,6 +125,35 @@ async function findClaudeInPath(): Promise<string | undefined> {
}); });
} }
/**
* Find Codex in PATH using 'which' command
*/
async function findCodexInPath(): Promise<string | undefined> {
return new Promise((resolve) => {
const env = { ...process.env, PATH: getExpandedPath() };
const command = process.platform === 'win32' ? 'where' : 'which';
const proc = spawn(command, [CODEX_DEFAULT_COMMAND], { env });
let stdout = '';
proc.stdout?.on('data', (data) => {
stdout += data.toString();
});
proc.on('close', (code) => {
if (code === 0 && stdout.trim()) {
resolve(stdout.trim().split('\n')[0]); // First match
} else {
resolve(undefined);
}
});
proc.on('error', () => {
resolve(undefined);
});
});
}
/** /**
* Check if Claude Code is available * Check if Claude Code is available
* First checks for a custom path in settings, then falls back to PATH detection * First checks for a custom path in settings, then falls back to PATH detection
@@ -148,6 +185,33 @@ export async function detectClaude(): Promise<{ available: boolean; path?: strin
return { available: false }; return { available: false };
} }
/**
* Check if Codex CLI is available
* First checks for a custom path in settings, then falls back to PATH detection
*/
export async function detectCodex(): Promise<{ available: boolean; path?: string; source?: 'settings' | 'path' }> {
if (cachedCodexPath) {
return { available: true, path: cachedCodexPath, source: 'settings' };
}
const customPath = getAgentCustomPath('codex');
if (customPath) {
if (await isExecutable(customPath)) {
cachedCodexPath = customPath;
return { available: true, path: customPath, source: 'settings' };
}
console.error(`Warning: Custom Codex path "${customPath}" is not executable, falling back to PATH detection`);
}
const pathResult = await findCodexInPath();
if (pathResult) {
cachedCodexPath = pathResult;
return { available: true, path: pathResult, source: 'path' };
}
return { available: false };
}
/** /**
* Get the resolved Claude command/path for spawning * Get the resolved Claude command/path for spawning
* Uses cached path from detectClaude() or falls back to default command * Uses cached path from detectClaude() or falls back to default command
@@ -156,10 +220,18 @@ export function getClaudeCommand(): string {
return cachedClaudePath || CLAUDE_DEFAULT_COMMAND; return cachedClaudePath || CLAUDE_DEFAULT_COMMAND;
} }
/**
* Get the resolved Codex command/path for spawning
* Uses cached path from detectCodex() or falls back to default command
*/
export function getCodexCommand(): string {
return cachedCodexPath || CODEX_DEFAULT_COMMAND;
}
/** /**
* Spawn Claude Code with a prompt and return the result * Spawn Claude Code with a prompt and return the result
*/ */
export async function spawnAgent( async function spawnClaudeAgent(
cwd: string, cwd: string,
prompt: string, prompt: string,
agentSessionId?: string agentSessionId?: string
@@ -309,6 +381,157 @@ export async function spawnAgent(
}); });
} }
function mergeUsageStats(current: UsageStats | undefined, next: {
inputTokens: number;
outputTokens: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
costUsd?: number;
contextWindow?: number;
reasoningTokens?: number;
}): UsageStats {
const merged: UsageStats = {
inputTokens: (current?.inputTokens || 0) + (next.inputTokens || 0),
outputTokens: (current?.outputTokens || 0) + (next.outputTokens || 0),
cacheReadInputTokens: (current?.cacheReadInputTokens || 0) + (next.cacheReadTokens || 0),
cacheCreationInputTokens: (current?.cacheCreationInputTokens || 0) + (next.cacheCreationTokens || 0),
totalCostUsd: (current?.totalCostUsd || 0) + (next.costUsd || 0),
contextWindow: Math.max(current?.contextWindow || 0, next.contextWindow || 0),
reasoningTokens: (current?.reasoningTokens || 0) + (next.reasoningTokens || 0),
};
if (!next.reasoningTokens && !current?.reasoningTokens) {
delete merged.reasoningTokens;
}
return merged;
}
/**
* Spawn Codex with a prompt and return the result
*/
async function spawnCodexAgent(
cwd: string,
prompt: string,
agentSessionId?: string
): Promise<AgentResult> {
return new Promise((resolve) => {
const env: NodeJS.ProcessEnv = {
...process.env,
PATH: getExpandedPath(),
};
const args = [...CODEX_ARGS];
args.push('-C', cwd);
if (agentSessionId) {
args.push('resume', agentSessionId);
}
args.push('--', prompt);
const options: SpawnOptions = {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
};
const codexCommand = getCodexCommand();
const child = spawn(codexCommand, args, options);
const parser = new CodexOutputParser();
let jsonBuffer = '';
let result: string | undefined;
let sessionId: string | undefined;
let usageStats: UsageStats | undefined;
let stderr = '';
let errorText: string | undefined;
child.stdout?.on('data', (data: Buffer) => {
jsonBuffer += data.toString();
const lines = jsonBuffer.split('\n');
jsonBuffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const event = parser.parseJsonLine(line);
if (!event) continue;
if (event.type === 'init' && event.sessionId && !sessionId) {
sessionId = event.sessionId;
}
if (event.type === 'result' && event.text) {
result = result ? `${result}\n${event.text}` : event.text;
}
if (event.type === 'error' && event.text && !errorText) {
errorText = event.text;
}
const usage = parser.extractUsage(event);
if (usage) {
usageStats = mergeUsageStats(usageStats, usage);
}
}
});
child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.stdin?.end();
child.on('close', (code) => {
if (code === 0 && !errorText) {
resolve({
success: true,
response: result,
agentSessionId: sessionId,
usageStats,
});
} else {
resolve({
success: false,
error: errorText || stderr || `Process exited with code ${code}`,
agentSessionId: sessionId,
usageStats,
});
}
});
child.on('error', (error) => {
resolve({
success: false,
error: `Failed to spawn Codex: ${error.message}`,
});
});
});
}
/**
* Spawn an agent with a prompt and return the result
*/
export async function spawnAgent(
toolType: ToolType,
cwd: string,
prompt: string,
agentSessionId?: string
): Promise<AgentResult> {
if (toolType === 'codex') {
return spawnCodexAgent(cwd, prompt, agentSessionId);
}
if (toolType === 'claude' || toolType === 'claude-code') {
return spawnClaudeAgent(cwd, prompt, agentSessionId);
}
return {
success: false,
error: `Unsupported agent type for batch mode: ${toolType}`,
};
}
/** /**
* Read a markdown document and count unchecked tasks * Read a markdown document and count unchecked tasks
*/ */

View File

@@ -466,7 +466,7 @@ export async function* runPlaybook(
} }
// Spawn agent with combined prompt + document // Spawn agent with combined prompt + document
const result = await spawnAgent(session.cwd, finalPrompt); const result = await spawnAgent(session.toolType, session.cwd, finalPrompt);
const elapsedMs = Date.now() - taskStartTime; const elapsedMs = Date.now() - taskStartTime;
@@ -497,6 +497,7 @@ export async function* runPlaybook(
if (result.success && result.agentSessionId) { if (result.success && result.agentSessionId) {
// Request synopsis from the agent // Request synopsis from the agent
const synopsisResult = await spawnAgent( const synopsisResult = await spawnAgent(
session.toolType,
session.cwd, session.cwd,
BATCH_SYNOPSIS_PROMPT, BATCH_SYNOPSIS_PROMPT,
result.agentSessionId result.agentSessionId

View File

@@ -19,6 +19,10 @@ Maestro is an Electron desktop application for managing multiple AI coding assis
- **Git Branch:** {{GIT_BRANCH}} - **Git Branch:** {{GIT_BRANCH}}
- **Session ID:** {{AGENT_SESSION_ID}} - **Session ID:** {{AGENT_SESSION_ID}}
## Auto-run Documents
When a user wants an auto-run document, create a detailed multi-document, multi-point Markdown implementation plan in the `{{AUTORUN_FOLDER}}` folder. Use the format `$PREFIX-X.md`, where `X` is the phase number and `$PREFIX` is the effort name. Break phases by relevant context; do not mix unrelated task results in the same document. If working within a file, group and fix all type issues in that file together. If working with an MCP, keep all related tasks in the same document. Each task must be written as `- [ ] ...` so auto-run can execute and check them off with comments on completion. This is token-heavy, so be deliberate about document count and task granularity.
## Critical Directive: Directory Restrictions ## Critical Directive: Directory Restrictions
**You MUST only write files within your assigned working directory:** **You MUST only write files within your assigned working directory:**

View File

@@ -4,6 +4,10 @@ The user is continuing a previous planning session. Below are the existing Auto
{{EXISTING_DOCS}} {{EXISTING_DOCS}}
## Auto-run Documents
When a user wants an auto-run document, create a detailed multi-document, multi-point Markdown implementation plan in the `{{AUTORUN_FOLDER}}` folder. Use the format `$PREFIX-X.md`, where `X` is the phase number and `$PREFIX` is the effort name. Break phases by relevant context; do not mix unrelated task results in the same document. If working within a file, group and fix all type issues in that file together. If working with an MCP, keep all related tasks in the same document. Each task must be written as `- [ ] ...` so auto-run can execute and check them off with comments on completion. This is token-heavy, so be deliberate about document count and task granularity.
**Important:** When continuing from existing docs: **Important:** When continuing from existing docs:
- Start with higher confidence (60-70%) since you already have context - Start with higher confidence (60-70%) since you already have context
- Review the existing plans and ask if anything has changed or needs updating - Review the existing plans and ask if anything has changed or needs updating

View File

@@ -11,6 +11,10 @@ You will ONLY create or modify files within this directory:
Do not reference, create, or modify files outside this path. Do not reference, create, or modify files outside this path.
## Auto-run Documents
When a user wants an auto-run document, create a detailed multi-document, multi-point Markdown implementation plan in the `{{AUTORUN_FOLDER}}` folder. Use the format `$PREFIX-X.md`, where `X` is the phase number and `$PREFIX` is the effort name. Break phases by relevant context; do not mix unrelated task results in the same document. If working within a file, group and fix all type issues in that file together. If working with an MCP, keep all related tasks in the same document. Each task must be written as `- [ ] ...` so auto-run can execute and check them off with comments on completion. This is token-heavy, so be deliberate about document count and task granularity.
## Your Goal ## Your Goal
Through a brief, focused conversation: Through a brief, focused conversation:

View File

@@ -178,32 +178,6 @@ export function AgentErrorModal({
{new Date(error.timestamp).toLocaleTimeString()} {new Date(error.timestamp).toLocaleTimeString()}
</div> </div>
{/* Recovery hint - different message based on whether there are actions */}
{error.recoverable && recoveryActions.length > 0 && (
<div
className="text-xs p-3 rounded"
style={{
backgroundColor: `${errorColor}15`,
color: theme.colors.textMain,
}}
>
<span className="font-medium">Tip:</span> This error can be resolved.
Choose a recovery action below.
</div>
)}
{error.recoverable && recoveryActions.length === 0 && (
<div
className="text-xs p-3 rounded"
style={{
backgroundColor: `${errorColor}15`,
color: theme.colors.textMain,
}}
>
<span className="font-medium">Tip:</span> Simply send another message to
continue, or start a new session.
</div>
)}
{/* Collapsible JSON Details */} {/* Collapsible JSON Details */}
{hasJsonDetails && ( {hasJsonDetails && (
<div className="border rounded" style={{ borderColor: theme.colors.border }}> <div className="border rounded" style={{ borderColor: theme.colors.border }}>

View File

@@ -731,89 +731,29 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow
}; };
}, [searchQuery, file.content, isMarkdown, isImage, theme.colors.accent]); }, [searchQuery, file.content, isMarkdown, isImage, theme.colors.accent]);
// Highlight search matches in markdown preview (DOM-based highlighting after render) // Count search matches in markdown preview mode (no DOM manipulation to avoid React conflicts)
useEffect(() => { useEffect(() => {
if (!isMarkdown || markdownEditMode || !searchQuery.trim() || !markdownContainerRef.current) { if (!isMarkdown || markdownEditMode || !searchQuery.trim()) {
if (isMarkdown && !markdownEditMode) {
setTotalMatches(0);
setCurrentMatchIndex(0);
matchElementsRef.current = [];
}
return; return;
} }
const container = markdownContainerRef.current; // Count matches in the raw content
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
// Collect all text nodes
let node;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
// Escape regex special characters
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escapedQuery, 'gi'); const regex = new RegExp(escapedQuery, 'gi');
const matchElements: HTMLElement[] = []; const matches = file.content.match(regex);
const count = matches ? matches.length : 0;
// Highlight matches using safe DOM methods setTotalMatches(count);
textNodes.forEach(textNode => { if (count > 0 && currentMatchIndex >= count) {
const text = textNode.textContent || ''; setCurrentMatchIndex(0);
const matches = text.match(regex);
if (matches) {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
text.replace(regex, (match, offset) => {
// Add text before match
if (offset > lastIndex) {
fragment.appendChild(document.createTextNode(text.substring(lastIndex, offset)));
}
// Add highlighted match
const mark = document.createElement('mark');
mark.style.backgroundColor = '#ffd700';
mark.style.color = '#000';
mark.style.padding = '0 2px';
mark.style.borderRadius = '2px';
mark.className = 'search-match-md';
mark.textContent = match;
fragment.appendChild(mark);
matchElements.push(mark);
lastIndex = offset + match.length;
return match;
});
// Add remaining text
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
}
textNode.parentNode?.replaceChild(fragment, textNode);
}
});
// Store match elements and update count
matchElementsRef.current = matchElements;
setTotalMatches(matchElements.length);
setCurrentMatchIndex(matchElements.length > 0 ? 0 : -1);
// Highlight first match with different color and scroll to it
if (matchElements.length > 0) {
matchElements[0].style.backgroundColor = theme.colors.accent;
matchElements[0].style.color = '#fff';
matchElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
matchElementsRef.current = [];
// Cleanup function to remove highlights }, [searchQuery, file.content, isMarkdown, markdownEditMode, currentMatchIndex]);
return () => {
container.querySelectorAll('mark.search-match-md').forEach(mark => {
const parent = mark.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(mark.textContent || ''), mark);
parent.normalize();
}
});
};
}, [searchQuery, file.content, isMarkdown, markdownEditMode, theme.colors.accent]);
const [copyNotificationMessage, setCopyNotificationMessage] = useState(''); const [copyNotificationMessage, setCopyNotificationMessage] = useState('');
@@ -851,47 +791,53 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow
// Navigate to next search match // Navigate to next search match
const goToNextMatch = () => { const goToNextMatch = () => {
if (totalMatches === 0) return; if (totalMatches === 0) return;
const matches = matchElementsRef.current;
// Reset current match highlight
if (matches[currentMatchIndex]) {
matches[currentMatchIndex].style.backgroundColor = '#ffd700';
matches[currentMatchIndex].style.color = '#000';
}
// Move to next match (wrap around) // Move to next match (wrap around)
const nextIndex = (currentMatchIndex + 1) % totalMatches; const nextIndex = (currentMatchIndex + 1) % totalMatches;
setCurrentMatchIndex(nextIndex); setCurrentMatchIndex(nextIndex);
// Highlight new current match and scroll to it // For code files, handle DOM-based highlighting
if (matches[nextIndex]) { const matches = matchElementsRef.current;
matches[nextIndex].style.backgroundColor = theme.colors.accent; if (matches.length > 0) {
matches[nextIndex].style.color = '#fff'; // Reset previous highlight
matches[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); if (matches[currentMatchIndex]) {
matches[currentMatchIndex].style.backgroundColor = '#ffd700';
matches[currentMatchIndex].style.color = '#000';
}
// Highlight new current match and scroll to it
if (matches[nextIndex]) {
matches[nextIndex].style.backgroundColor = theme.colors.accent;
matches[nextIndex].style.color = '#fff';
matches[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} }
// For markdown edit mode, the effect will handle selecting text
}; };
// Navigate to previous search match // Navigate to previous search match
const goToPrevMatch = () => { const goToPrevMatch = () => {
if (totalMatches === 0) return; if (totalMatches === 0) return;
const matches = matchElementsRef.current;
// Reset current match highlight
if (matches[currentMatchIndex]) {
matches[currentMatchIndex].style.backgroundColor = '#ffd700';
matches[currentMatchIndex].style.color = '#000';
}
// Move to previous match (wrap around) // Move to previous match (wrap around)
const prevIndex = (currentMatchIndex - 1 + totalMatches) % totalMatches; const prevIndex = (currentMatchIndex - 1 + totalMatches) % totalMatches;
setCurrentMatchIndex(prevIndex); setCurrentMatchIndex(prevIndex);
// Highlight new current match and scroll to it // For code files, handle DOM-based highlighting
if (matches[prevIndex]) { const matches = matchElementsRef.current;
matches[prevIndex].style.backgroundColor = theme.colors.accent; if (matches.length > 0) {
matches[prevIndex].style.color = '#fff'; // Reset previous highlight
matches[prevIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); if (matches[currentMatchIndex]) {
matches[currentMatchIndex].style.backgroundColor = '#ffd700';
matches[currentMatchIndex].style.color = '#000';
}
// Highlight new current match and scroll to it
if (matches[prevIndex]) {
matches[prevIndex].style.backgroundColor = theme.colors.accent;
matches[prevIndex].style.color = '#fff';
matches[prevIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} }
// For markdown edit mode, the effect will handle selecting text
}; };
// Format shortcut keys for display // Format shortcut keys for display
@@ -901,25 +847,6 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow
return formatShortcutKeys(shortcut.keys); return formatShortcutKeys(shortcut.keys);
}; };
// Navigate to current match for markdown content (using stored match elements)
useEffect(() => {
if (isMarkdown && searchQuery.trim() && !markdownEditMode) {
const matches = matchElementsRef.current;
if (matches.length > 0 && currentMatchIndex >= 0 && currentMatchIndex < matches.length) {
matches.forEach((mark, i) => {
if (i === currentMatchIndex) {
mark.style.backgroundColor = theme.colors.accent;
mark.style.color = '#fff';
mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
mark.style.backgroundColor = '#ffd700';
mark.style.color = '#000';
}
});
}
}
}, [currentMatchIndex, isMarkdown, markdownEditMode, searchQuery, theme.colors.accent]);
// Handle search in markdown edit mode - jump to and select the match in textarea // Handle search in markdown edit mode - jump to and select the match in textarea
useEffect(() => { useEffect(() => {
if (!isMarkdown || !markdownEditMode || !searchQuery.trim() || !textareaRef.current) { if (!isMarkdown || !markdownEditMode || !searchQuery.trim() || !textareaRef.current) {

View File

@@ -6,7 +6,7 @@
*/ */
import { useRef, useEffect, useCallback, useMemo, useState } from 'react'; import { useRef, useEffect, useCallback, useMemo, useState } from 'react';
import { BookOpen, Eye, FileText, Copy, ChevronDown, ChevronUp } from 'lucide-react'; import { Eye, FileText, Copy, ChevronDown, ChevronUp } from 'lucide-react';
import type { GroupChatMessage, GroupChatParticipant, GroupChatState, Theme } from '../types'; import type { GroupChatMessage, GroupChatParticipant, GroupChatState, Theme } from '../types';
import { MarkdownRenderer } from './MarkdownRenderer'; import { MarkdownRenderer } from './MarkdownRenderer';
import { stripMarkdown } from '../utils/textProcessing'; import { stripMarkdown } from '../utils/textProcessing';
@@ -198,7 +198,7 @@ export function GroupChatMessages({
{/* Message bubble */} {/* Message bubble */}
<div <div
className={`flex-1 min-w-0 p-4 pb-10 ${isUser && msg.readOnly ? 'pt-8' : ''} rounded-xl border ${isUser ? 'rounded-tr-none' : 'rounded-tl-none'} relative overflow-hidden`} className={`flex-1 min-w-0 p-4 pb-10 rounded-xl border ${isUser ? 'rounded-tr-none' : 'rounded-tl-none'} relative overflow-hidden`}
style={{ style={{
backgroundColor: isUser backgroundColor: isUser
? `color-mix(in srgb, ${theme.colors.accent} 20%, ${theme.colors.bgSidebar})` ? `color-mix(in srgb, ${theme.colors.accent} 20%, ${theme.colors.bgSidebar})`
@@ -211,24 +211,6 @@ export function GroupChatMessages({
color: theme.colors.textMain, color: theme.colors.textMain,
}} }}
> >
{/* Read-only badge for user messages */}
{isUser && msg.readOnly && (
<div className="absolute top-2 right-2">
<span
className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full"
style={{
backgroundColor: `${theme.colors.warning}25`,
color: theme.colors.warning,
border: `1px solid ${theme.colors.warning}50`
}}
title="Sent in read-only mode"
>
<BookOpen className="w-3 h-3" />
<span>Read-only</span>
</span>
</div>
)}
{/* Sender label for non-user messages */} {/* Sender label for non-user messages */}
{!isUser && ( {!isUser && (
<div <div

View File

@@ -123,9 +123,19 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
? hasCapability('supportsImageInputOnResume') ? hasCapability('supportsImageInputOnResume')
: hasCapability('supportsImageInput'); : hasCapability('supportsImageInput');
// Check if we're in read-only mode (auto mode OR manual toggle - Claude will be in plan mode) // Check if we're in read-only mode (manual toggle only - Claude will be in plan mode)
const isAutoReadOnly = isAutoModeActive && session.inputMode === 'ai'; // NOTE: Auto Run no longer forces read-only mode. Instead:
const isReadOnlyMode = isAutoReadOnly || (tabReadOnlyMode && session.inputMode === 'ai'); // - Yellow border shows during Auto Run to indicate queuing will happen for write messages
// - User can freely toggle read-only mode during Auto Run
// - If read-only is ON: message sends immediately (parallel read-only operations allowed)
// - If read-only is OFF: message queues until Auto Run completes (prevents file conflicts)
const isReadOnlyMode = tabReadOnlyMode && session.inputMode === 'ai';
// Check if Auto Run is active - used for yellow border indication (queuing will happen for write messages)
const isAutoRunActive = isAutoModeActive && session.inputMode === 'ai';
// Show yellow border when: read-only mode is on OR Auto Run is active (both indicate special input handling)
const showQueueingBorder = isReadOnlyMode || isAutoRunActive;
// Filter slash commands based on input and current mode // Filter slash commands based on input and current mode
const isTerminalMode = session.inputMode === 'terminal'; const isTerminalMode = session.inputMode === 'terminal';
@@ -539,8 +549,8 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
<div <div
className="flex-1 relative border rounded-lg bg-opacity-50 flex flex-col" className="flex-1 relative border rounded-lg bg-opacity-50 flex flex-col"
style={{ style={{
borderColor: isReadOnlyMode ? theme.colors.warning : theme.colors.border, borderColor: showQueueingBorder ? theme.colors.warning : theme.colors.border,
backgroundColor: isReadOnlyMode ? `${theme.colors.warning}15` : theme.colors.bgMain backgroundColor: showQueueingBorder ? `${theme.colors.warning}15` : theme.colors.bgMain
}} }}
> >
<div className="flex items-start"> <div className="flex items-start">
@@ -704,14 +714,11 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
</button> </button>
)} )}
{/* Read-only mode toggle - AI mode only, if agent supports it */} {/* Read-only mode toggle - AI mode only, if agent supports it */}
{/* When AutoRun is active, pill is locked to enabled state */} {/* User can freely toggle read-only during Auto Run */}
{session.inputMode === 'ai' && onToggleTabReadOnlyMode && hasCapability('supportsReadOnlyMode') && ( {session.inputMode === 'ai' && onToggleTabReadOnlyMode && hasCapability('supportsReadOnlyMode') && (
<button <button
onClick={isAutoReadOnly ? undefined : onToggleTabReadOnlyMode} onClick={onToggleTabReadOnlyMode}
disabled={isAutoReadOnly} className={`flex items-center gap-1.5 text-[10px] px-2 py-1 rounded-full cursor-pointer transition-all ${
className={`flex items-center gap-1.5 text-[10px] px-2 py-1 rounded-full transition-all ${
isAutoReadOnly ? 'cursor-not-allowed' : 'cursor-pointer'
} ${
isReadOnlyMode ? '' : 'opacity-40 hover:opacity-70' isReadOnlyMode ? '' : 'opacity-40 hover:opacity-70'
}`} }`}
style={{ style={{
@@ -719,7 +726,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
color: isReadOnlyMode ? theme.colors.warning : theme.colors.textDim, color: isReadOnlyMode ? theme.colors.warning : theme.colors.textDim,
border: isReadOnlyMode ? `1px solid ${theme.colors.warning}50` : '1px solid transparent' border: isReadOnlyMode ? `1px solid ${theme.colors.warning}50` : '1px solid transparent'
}} }}
title={isAutoReadOnly ? "Read-only mode locked during AutoRun" : "Toggle read-only mode (agent won't modify files)"} title="Toggle read-only mode (agent won't modify files)"
> >
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
<span>Read-only</span> <span>Read-only</span>

View File

@@ -314,7 +314,7 @@ const LogItemComponent = memo(({
); );
})()} })()}
</div> </div>
<div className={`flex-1 min-w-0 p-4 pb-10 ${isUserMessage && log.readOnly ? 'pt-8' : ''} rounded-xl border ${isUserMessage ? 'rounded-tr-none' : 'rounded-tl-none'} relative overflow-hidden`} <div className={`flex-1 min-w-0 p-4 pb-10 rounded-xl border ${isUserMessage ? 'rounded-tr-none' : 'rounded-tl-none'} relative overflow-hidden`}
style={{ style={{
backgroundColor: isUserMessage backgroundColor: isUserMessage
? isAIMode ? isAIMode
@@ -327,23 +327,6 @@ const LogItemComponent = memo(({
? theme.colors.accent + '40' ? theme.colors.accent + '40'
: (log.source === 'stderr' || log.source === 'error') ? theme.colors.error : theme.colors.border : (log.source === 'stderr' || log.source === 'error') ? theme.colors.error : theme.colors.border
}}> }}>
{/* Read-only badge - top right of message for user messages sent in read-only mode */}
{isUserMessage && log.readOnly && (
<div className="absolute top-2 right-2">
<span
className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full"
style={{
backgroundColor: `${theme.colors.warning}25`,
color: theme.colors.warning,
border: `1px solid ${theme.colors.warning}50`
}}
title="Sent in read-only mode (Claude won't modify files)"
>
<Eye className="w-3 h-3" />
<span>Read-only</span>
</span>
</div>
)}
{/* Local filter icon for system output only */} {/* Local filter icon for system output only */}
{log.source !== 'user' && isTerminal && ( {log.source !== 'user' && isTerminal && (
<div className="absolute top-2 right-2 flex items-center gap-2"> <div className="absolute top-2 right-2 flex items-center gap-2">

View File

@@ -5,12 +5,15 @@ import { getBadgeForTime, getNextBadge, formatTimeRemaining } from '../constants
import { autorunSynopsisPrompt } from '../../prompts'; import { autorunSynopsisPrompt } from '../../prompts';
import { parseSynopsis } from '../../shared/synopsis'; import { parseSynopsis } from '../../shared/synopsis';
// Regex to count unchecked markdown checkboxes: - [ ] task // Regex to count unchecked markdown checkboxes: - [ ] task (also * [ ])
const UNCHECKED_TASK_REGEX = /^[\s]*-\s*\[\s*\]\s*.+$/gm; const UNCHECKED_TASK_REGEX = /^[\s]*[-*]\s*\[\s*\]\s*.+$/gm;
// Regex to count checked markdown checkboxes: - [x] task (also * [x])
const CHECKED_TASK_COUNT_REGEX = /^[\s]*[-*]\s*\[[xX✓✔]\]\s*.+$/gm;
// Regex to match checked markdown checkboxes for reset-on-completion // Regex to match checked markdown checkboxes for reset-on-completion
// Matches both [x] and [X] with various checkbox formats (standard and GitHub-style) // Matches both [x] and [X] with various checkbox formats (standard and GitHub-style)
const CHECKED_TASK_REGEX = /^(\s*-\s*)\[[xX✓✔]\]/gm; const CHECKED_TASK_REGEX = /^(\s*[-*]\s*)\[[xX✓✔]\]/gm;
// Default empty batch state // Default empty batch state
const DEFAULT_BATCH_STATE: BatchRunState = { const DEFAULT_BATCH_STATE: BatchRunState = {
@@ -197,6 +200,14 @@ export function countUnfinishedTasks(content: string): number {
return matches ? matches.length : 0; return matches ? matches.length : 0;
} }
/**
* Count checked tasks in markdown content
*/
function countCheckedTasks(content: string): number {
const matches = content.match(CHECKED_TASK_COUNT_REGEX);
return matches ? matches.length : 0;
}
/** /**
* Uncheck all markdown checkboxes in content (for reset-on-completion) * Uncheck all markdown checkboxes in content (for reset-on-completion)
* Converts all - [x] to - [ ] (case insensitive) * Converts all - [x] to - [ ] (case insensitive)
@@ -708,6 +719,8 @@ ${docList}
// Read document and count tasks // Read document and count tasks
let { taskCount: remainingTasks, content: docContent } = await readDocAndCountTasks(folderPath, docEntry.filename); let { taskCount: remainingTasks, content: docContent } = await readDocAndCountTasks(folderPath, docEntry.filename);
let docCheckedCount = countCheckedTasks(docContent);
let docTasksTotal = remainingTasks;
// Handle documents with no unchecked tasks // Handle documents with no unchecked tasks
if (remainingTasks === 0) { if (remainingTasks === 0) {
@@ -756,7 +769,7 @@ ${docList}
[sessionId]: { [sessionId]: {
...prev[sessionId], ...prev[sessionId],
currentDocumentIndex: docIndex, currentDocumentIndex: docIndex,
currentDocTasksTotal: remainingTasks, currentDocTasksTotal: docTasksTotal,
currentDocTasksCompleted: 0 currentDocTasksCompleted: 0
} }
})); }));
@@ -820,8 +833,11 @@ ${docList}
// Re-read document to get updated task count and content // Re-read document to get updated task count and content
const { taskCount: newRemainingTasks, content: contentAfterTask } = await readDocAndCountTasks(folderPath, docEntry.filename); const { taskCount: newRemainingTasks, content: contentAfterTask } = await readDocAndCountTasks(folderPath, docEntry.filename);
// Calculate tasks completed - ensure it's never negative (Claude may have added tasks) const newCheckedCount = countCheckedTasks(contentAfterTask);
const tasksCompletedThisRun = Math.max(0, remainingTasks - newRemainingTasks); // Calculate tasks completed based on newly checked tasks.
// This remains accurate even if new unchecked tasks are added.
const tasksCompletedThisRun = Math.max(0, newCheckedCount - docCheckedCount);
const addedUncheckedTasks = Math.max(0, newRemainingTasks - remainingTasks);
// Detect stalling: if document content is unchanged and no tasks were checked off // Detect stalling: if document content is unchanged and no tasks were checked off
const documentUnchanged = contentBeforeTask === contentAfterTask; const documentUnchanged = contentBeforeTask === contentAfterTask;
@@ -855,18 +871,30 @@ ${docList}
} }
// Update progress state // Update progress state
updateBatchStateAndBroadcast(sessionId, prev => ({ if (addedUncheckedTasks > 0) {
...prev, docTasksTotal += addedUncheckedTasks;
[sessionId]: { }
...prev[sessionId],
currentDocTasksCompleted: docTasksCompleted, updateBatchStateAndBroadcast(sessionId, prev => {
completedTasksAcrossAllDocs: totalCompletedTasks, const prevState = prev[sessionId];
// Legacy fields const nextTotalAcrossAllDocs = Math.max(0, prevState.totalTasksAcrossAllDocs + addedUncheckedTasks);
completedTasks: totalCompletedTasks, const nextTotalTasks = Math.max(0, prevState.totalTasks + addedUncheckedTasks);
currentTaskIndex: totalCompletedTasks, return {
sessionIds: [...(prev[sessionId]?.sessionIds || []), result.agentSessionId || ''] ...prev,
} [sessionId]: {
})); ...prevState,
currentDocTasksCompleted: docTasksCompleted,
currentDocTasksTotal: docTasksTotal,
completedTasksAcrossAllDocs: totalCompletedTasks,
totalTasksAcrossAllDocs: nextTotalAcrossAllDocs,
// Legacy fields
completedTasks: totalCompletedTasks,
totalTasks: nextTotalTasks,
currentTaskIndex: totalCompletedTasks,
sessionIds: [...(prevState?.sessionIds || []), result.agentSessionId || '']
}
};
});
// Generate synopsis for successful tasks with an agent session // Generate synopsis for successful tasks with an agent session
let shortSummary = `[${docEntry.filename}] Task completed`; let shortSummary = `[${docEntry.filename}] Task completed`;
@@ -977,6 +1005,7 @@ ${docList}
break; // Break out of the inner while loop for this document break; // Break out of the inner while loop for this document
} }
docCheckedCount = newCheckedCount;
remainingTasks = newRemainingTasks; remainingTasks = newRemainingTasks;
console.log(`[BatchProcessor] Document ${docEntry.filename}: ${remainingTasks} tasks remaining`); console.log(`[BatchProcessor] Document ${docEntry.filename}: ${remainingTasks} tasks remaining`);

View File

@@ -305,19 +305,20 @@ export function useBatchedSessionUpdates(
// Session-level usage // Session-level usage
const sessionUsageDelta = acc.usageDeltas.get(null); const sessionUsageDelta = acc.usageDeltas.get(null);
if (sessionUsageDelta) { if (sessionUsageDelta) {
const existing = updatedSession.usageStats; const existing = updatedSession.usageStats;
updatedSession = { updatedSession = {
...updatedSession, ...updatedSession,
usageStats: { usageStats: {
inputTokens: (existing?.inputTokens || 0) + sessionUsageDelta.inputTokens, inputTokens: (existing?.inputTokens || 0) + sessionUsageDelta.inputTokens,
outputTokens: (existing?.outputTokens || 0) + sessionUsageDelta.outputTokens, outputTokens: (existing?.outputTokens || 0) + sessionUsageDelta.outputTokens,
cacheReadInputTokens: (existing?.cacheReadInputTokens || 0) + sessionUsageDelta.cacheReadInputTokens, cacheReadInputTokens: (existing?.cacheReadInputTokens || 0) + sessionUsageDelta.cacheReadInputTokens,
cacheCreationInputTokens: (existing?.cacheCreationInputTokens || 0) + sessionUsageDelta.cacheCreationInputTokens, cacheCreationInputTokens: (existing?.cacheCreationInputTokens || 0) + sessionUsageDelta.cacheCreationInputTokens,
totalCostUsd: (existing?.totalCostUsd || 0) + sessionUsageDelta.totalCostUsd, totalCostUsd: (existing?.totalCostUsd || 0) + sessionUsageDelta.totalCostUsd,
contextWindow: sessionUsageDelta.contextWindow reasoningTokens: (existing?.reasoningTokens || 0) + (sessionUsageDelta.reasoningTokens || 0),
contextWindow: sessionUsageDelta.contextWindow
}
};
} }
};
}
// Tab-level usage // Tab-level usage
if (updatedSession.aiTabs) { if (updatedSession.aiTabs) {
@@ -335,8 +336,9 @@ export function useBatchedSessionUpdates(
cacheReadInputTokens: tabUsageDelta.cacheReadInputTokens, cacheReadInputTokens: tabUsageDelta.cacheReadInputTokens,
cacheCreationInputTokens: tabUsageDelta.cacheCreationInputTokens, cacheCreationInputTokens: tabUsageDelta.cacheCreationInputTokens,
contextWindow: tabUsageDelta.contextWindow, contextWindow: tabUsageDelta.contextWindow,
outputTokens: (existing?.outputTokens || 0) + tabUsageDelta.outputTokens, outputTokens: tabUsageDelta.outputTokens, // Current (not accumulated)
totalCostUsd: (existing?.totalCostUsd || 0) + tabUsageDelta.totalCostUsd totalCostUsd: (existing?.totalCostUsd || 0) + tabUsageDelta.totalCostUsd,
reasoningTokens: tabUsageDelta.reasoningTokens
} }
}; };
}) })
@@ -490,8 +492,9 @@ export function useBatchedSessionUpdates(
cacheReadInputTokens: usage.cacheReadInputTokens, cacheReadInputTokens: usage.cacheReadInputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens, cacheCreationInputTokens: usage.cacheCreationInputTokens,
contextWindow: usage.contextWindow, contextWindow: usage.contextWindow,
outputTokens: existing.outputTokens + usage.outputTokens, outputTokens: usage.outputTokens,
totalCostUsd: existing.totalCostUsd + usage.totalCostUsd totalCostUsd: existing.totalCostUsd + usage.totalCostUsd,
reasoningTokens: usage.reasoningTokens
}); });
} else { } else {
// Session-level: all values are accumulated // Session-level: all values are accumulated
@@ -501,6 +504,7 @@ export function useBatchedSessionUpdates(
cacheReadInputTokens: existing.cacheReadInputTokens + usage.cacheReadInputTokens, cacheReadInputTokens: existing.cacheReadInputTokens + usage.cacheReadInputTokens,
cacheCreationInputTokens: existing.cacheCreationInputTokens + usage.cacheCreationInputTokens, cacheCreationInputTokens: existing.cacheCreationInputTokens + usage.cacheCreationInputTokens,
totalCostUsd: existing.totalCostUsd + usage.totalCostUsd, totalCostUsd: existing.totalCostUsd + usage.totalCostUsd,
reasoningTokens: (existing.reasoningTokens || 0) + (usage.reasoningTokens || 0),
contextWindow: usage.contextWindow contextWindow: usage.contextWindow
}); });
} }

4
src/types/vite-raw.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*?raw' {
const content: string;
export default content;
}

13
tsconfig.lint.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": [
"src/renderer",
"src/shared",
"src/web",
"src/types"
]
}

View File

@@ -9,7 +9,14 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'], setupFiles: ['./src/__tests__/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'], include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', 'release', 'src/__tests__/integration/**'], exclude: [
'node_modules',
'dist',
'release',
'src/__tests__/integration/**',
'src/__tests__/e2e/**',
'src/__tests__/performance/**',
],
testTimeout: 10000, testTimeout: 10000,
hookTimeout: 10000, hookTimeout: 10000,
teardownTimeout: 5000, teardownTimeout: 5000,