From ef20df59c0ad71a55fcf787d9c03ec4923fdd64d Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sat, 20 Dec 2025 02:51:32 -0600 Subject: [PATCH] added debug support package production --- package.json | 1 + .../renderer/components/FilePreview.test.tsx | 13 +- .../components/TerminalOutput.test.tsx | 16 -- src/__tests__/setup.ts | 19 +- src/cli/commands/run-playbook.ts | 44 +++- src/cli/services/agent-spawner.ts | 229 +++++++++++++++++- src/cli/services/batch-processor.ts | 3 +- src/prompts/maestro-system-prompt.md | 4 + src/prompts/wizard-system-continuation.md | 4 + src/prompts/wizard-system.md | 4 + src/renderer/components/AgentErrorModal.tsx | 26 -- src/renderer/components/FilePreview.tsx | 163 ++++--------- src/renderer/components/GroupChatMessages.tsx | 22 +- src/renderer/components/InputArea.tsx | 31 ++- src/renderer/components/TerminalOutput.tsx | 19 +- src/renderer/hooks/useBatchProcessor.ts | 65 +++-- .../hooks/useBatchedSessionUpdates.ts | 36 +-- src/types/vite-raw.d.ts | 4 + tsconfig.lint.json | 13 + vitest.config.mts | 9 +- 20 files changed, 458 insertions(+), 267 deletions(-) create mode 100644 src/types/vite-raw.d.ts create mode 100644 tsconfig.lint.json diff --git a/package.json b/package.json index 8a14eeeb..cfd88fb7 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "start": "electron .", "clean": "rm -rf dist release node_modules/.vite", "postinstall": "electron-rebuild -f -w node-pty", + "lint": "tsc -p tsconfig.lint.json", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/src/__tests__/renderer/components/FilePreview.test.tsx b/src/__tests__/renderer/components/FilePreview.test.tsx index f2b404f2..c71e3e35 100644 --- a/src/__tests__/renderer/components/FilePreview.test.tsx +++ b/src/__tests__/renderer/components/FilePreview.test.tsx @@ -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( { /> ); + // Verify we start in preview mode (ReactMarkdown is rendered) + expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); + // Open search const container = screen.getByText('test.md').closest('[tabindex="0"]'); fireEvent.keyDown(container!, { key: 'f', metaKey: true }); @@ -2157,14 +2160,14 @@ describe('Search in markdown with highlighting', () => { 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/); 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(() => { - // When searching in markdown, it shows raw content with highlights - expect(screen.getByText('1/1')).toBeInTheDocument(); + expect(screen.getByTestId('react-markdown')).toBeInTheDocument(); }); }); }); diff --git a/src/__tests__/renderer/components/TerminalOutput.test.tsx b/src/__tests__/renderer/components/TerminalOutput.test.tsx index 7e7735b3..e0d1608c 100644 --- a/src/__tests__/renderer/components/TerminalOutput.test.tsx +++ b/src/__tests__/renderer/components/TerminalOutput.test.tsx @@ -233,22 +233,6 @@ describe('TerminalOutput', () => { 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(); - - expect(screen.getByText('Read-only')).toBeInTheDocument(); - }); - it('shows delivered checkmark for delivered messages', () => { const logs: LogEntry[] = [ createLogEntry({ text: 'Delivered message', source: 'user', delivered: true }), diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 64e25ad2..50c6305e 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -23,7 +23,7 @@ vi.mock('lucide-react', () => { return new Proxy({}, { get(_target, prop: string) { // Ignore internal properties - if (prop === '__esModule' || prop === 'default' || typeof prop === 'symbol') { + if (prop === '__esModule' || prop === 'default' || prop === 'then' || typeof prop === 'symbol') { return undefined; } @@ -33,6 +33,23 @@ vi.mock('lucide-react', () => { } 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), + }; + }, }); }); diff --git a/src/cli/commands/run-playbook.ts b/src/cli/commands/run-playbook.ts index 6ace4c2d..73f32d4c 100644 --- a/src/cli/commands/run-playbook.ts +++ b/src/cli/commands/run-playbook.ts @@ -4,7 +4,7 @@ import { getSessionById } from '../services/storage'; import { findPlaybookById } from '../services/playbooks'; 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 { formatRunEvent, formatError, formatInfo, formatWarning, RunEvent } from '../output/formatter'; import { isSessionBusyWithCli, getCliActivityForSession } from '../../shared/cli-activity'; @@ -94,17 +94,6 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption const useJson = options.json; 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 playbook; @@ -125,6 +114,37 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption 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) let busyCheck = checkAgentBusy(agent.id, agent.name); diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 8ff55f1b..eeebb2a0 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -1,10 +1,11 @@ // 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 * as os from 'os'; 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'; // 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) 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 export interface AgentResult { success: boolean; @@ -117,6 +125,35 @@ async function findClaudeInPath(): Promise { }); } +/** + * Find Codex in PATH using 'which' command + */ +async function findCodexInPath(): Promise { + 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 * 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 }; } +/** + * 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 * Uses cached path from detectClaude() or falls back to default command @@ -156,10 +220,18 @@ export function getClaudeCommand(): string { 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 */ -export async function spawnAgent( +async function spawnClaudeAgent( cwd: string, prompt: 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 { + 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 { + 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 */ diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 616425a3..2c97c4c3 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -466,7 +466,7 @@ export async function* runPlaybook( } // 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; @@ -497,6 +497,7 @@ export async function* runPlaybook( if (result.success && result.agentSessionId) { // Request synopsis from the agent const synopsisResult = await spawnAgent( + session.toolType, session.cwd, BATCH_SYNOPSIS_PROMPT, result.agentSessionId diff --git a/src/prompts/maestro-system-prompt.md b/src/prompts/maestro-system-prompt.md index 983fcdcb..d18d9106 100644 --- a/src/prompts/maestro-system-prompt.md +++ b/src/prompts/maestro-system-prompt.md @@ -19,6 +19,10 @@ Maestro is an Electron desktop application for managing multiple AI coding assis - **Git Branch:** {{GIT_BRANCH}} - **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 **You MUST only write files within your assigned working directory:** diff --git a/src/prompts/wizard-system-continuation.md b/src/prompts/wizard-system-continuation.md index 586b8c9a..1820c1b0 100644 --- a/src/prompts/wizard-system-continuation.md +++ b/src/prompts/wizard-system-continuation.md @@ -4,6 +4,10 @@ The user is continuing a previous planning session. Below are the existing Auto {{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: - Start with higher confidence (60-70%) since you already have context - Review the existing plans and ask if anything has changed or needs updating diff --git a/src/prompts/wizard-system.md b/src/prompts/wizard-system.md index 859105bb..64fb1a06 100644 --- a/src/prompts/wizard-system.md +++ b/src/prompts/wizard-system.md @@ -11,6 +11,10 @@ You will ONLY create or modify files within this directory: 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 Through a brief, focused conversation: diff --git a/src/renderer/components/AgentErrorModal.tsx b/src/renderer/components/AgentErrorModal.tsx index 3c7e8660..03e99a62 100644 --- a/src/renderer/components/AgentErrorModal.tsx +++ b/src/renderer/components/AgentErrorModal.tsx @@ -178,32 +178,6 @@ export function AgentErrorModal({ {new Date(error.timestamp).toLocaleTimeString()} - {/* Recovery hint - different message based on whether there are actions */} - {error.recoverable && recoveryActions.length > 0 && ( -
- Tip: This error can be resolved. - Choose a recovery action below. -
- )} - {error.recoverable && recoveryActions.length === 0 && ( -
- Tip: Simply send another message to - continue, or start a new session. -
- )} - {/* Collapsible JSON Details */} {hasJsonDetails && (
diff --git a/src/renderer/components/FilePreview.tsx b/src/renderer/components/FilePreview.tsx index d662f39f..2f64139e 100644 --- a/src/renderer/components/FilePreview.tsx +++ b/src/renderer/components/FilePreview.tsx @@ -731,89 +731,29 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow }; }, [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(() => { - if (!isMarkdown || markdownEditMode || !searchQuery.trim() || !markdownContainerRef.current) { + if (!isMarkdown || markdownEditMode || !searchQuery.trim()) { + if (isMarkdown && !markdownEditMode) { + setTotalMatches(0); + setCurrentMatchIndex(0); + matchElementsRef.current = []; + } return; } - const container = markdownContainerRef.current; - 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 + // Count matches in the raw content const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 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 - textNodes.forEach(textNode => { - const text = textNode.textContent || ''; - 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' }); + setTotalMatches(count); + if (count > 0 && currentMatchIndex >= count) { + setCurrentMatchIndex(0); } - - // Cleanup function to remove highlights - 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]); + matchElementsRef.current = []; + }, [searchQuery, file.content, isMarkdown, markdownEditMode, currentMatchIndex]); const [copyNotificationMessage, setCopyNotificationMessage] = useState(''); @@ -851,47 +791,53 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow // Navigate to next search match const goToNextMatch = () => { 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) const nextIndex = (currentMatchIndex + 1) % totalMatches; setCurrentMatchIndex(nextIndex); - // 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 code files, handle DOM-based highlighting + const matches = matchElementsRef.current; + if (matches.length > 0) { + // Reset previous highlight + 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 const goToPrevMatch = () => { 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) const prevIndex = (currentMatchIndex - 1 + totalMatches) % totalMatches; setCurrentMatchIndex(prevIndex); - // 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 code files, handle DOM-based highlighting + const matches = matchElementsRef.current; + if (matches.length > 0) { + // Reset previous highlight + 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 @@ -901,25 +847,6 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow 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 useEffect(() => { if (!isMarkdown || !markdownEditMode || !searchQuery.trim() || !textareaRef.current) { diff --git a/src/renderer/components/GroupChatMessages.tsx b/src/renderer/components/GroupChatMessages.tsx index f7e2027f..3147c4fd 100644 --- a/src/renderer/components/GroupChatMessages.tsx +++ b/src/renderer/components/GroupChatMessages.tsx @@ -6,7 +6,7 @@ */ 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 { MarkdownRenderer } from './MarkdownRenderer'; import { stripMarkdown } from '../utils/textProcessing'; @@ -198,7 +198,7 @@ export function GroupChatMessages({ {/* Message bubble */}
- {/* Read-only badge for user messages */} - {isUser && msg.readOnly && ( -
- - - Read-only - -
- )} - {/* Sender label for non-user messages */} {!isUser && (
@@ -704,14 +714,11 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { )} {/* 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') && (
-
- {/* Read-only badge - top right of message for user messages sent in read-only mode */} - {isUserMessage && log.readOnly && ( -
- - - Read-only - -
- )} {/* Local filter icon for system output only */} {log.source !== 'user' && isTerminal && (
diff --git a/src/renderer/hooks/useBatchProcessor.ts b/src/renderer/hooks/useBatchProcessor.ts index 8f407d7c..ec1db0bd 100644 --- a/src/renderer/hooks/useBatchProcessor.ts +++ b/src/renderer/hooks/useBatchProcessor.ts @@ -5,12 +5,15 @@ import { getBadgeForTime, getNextBadge, formatTimeRemaining } from '../constants import { autorunSynopsisPrompt } from '../../prompts'; import { parseSynopsis } from '../../shared/synopsis'; -// Regex to count unchecked markdown checkboxes: - [ ] task -const UNCHECKED_TASK_REGEX = /^[\s]*-\s*\[\s*\]\s*.+$/gm; +// Regex to count unchecked markdown checkboxes: - [ ] task (also * [ ]) +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 // 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 const DEFAULT_BATCH_STATE: BatchRunState = { @@ -197,6 +200,14 @@ export function countUnfinishedTasks(content: string): number { 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) * Converts all - [x] to - [ ] (case insensitive) @@ -708,6 +719,8 @@ ${docList} // Read document and count tasks let { taskCount: remainingTasks, content: docContent } = await readDocAndCountTasks(folderPath, docEntry.filename); + let docCheckedCount = countCheckedTasks(docContent); + let docTasksTotal = remainingTasks; // Handle documents with no unchecked tasks if (remainingTasks === 0) { @@ -756,7 +769,7 @@ ${docList} [sessionId]: { ...prev[sessionId], currentDocumentIndex: docIndex, - currentDocTasksTotal: remainingTasks, + currentDocTasksTotal: docTasksTotal, currentDocTasksCompleted: 0 } })); @@ -820,8 +833,11 @@ ${docList} // Re-read document to get updated task count and content const { taskCount: newRemainingTasks, content: contentAfterTask } = await readDocAndCountTasks(folderPath, docEntry.filename); - // Calculate tasks completed - ensure it's never negative (Claude may have added tasks) - const tasksCompletedThisRun = Math.max(0, remainingTasks - newRemainingTasks); + const newCheckedCount = countCheckedTasks(contentAfterTask); + // 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 const documentUnchanged = contentBeforeTask === contentAfterTask; @@ -855,18 +871,30 @@ ${docList} } // Update progress state - updateBatchStateAndBroadcast(sessionId, prev => ({ - ...prev, - [sessionId]: { - ...prev[sessionId], - currentDocTasksCompleted: docTasksCompleted, - completedTasksAcrossAllDocs: totalCompletedTasks, - // Legacy fields - completedTasks: totalCompletedTasks, - currentTaskIndex: totalCompletedTasks, - sessionIds: [...(prev[sessionId]?.sessionIds || []), result.agentSessionId || ''] - } - })); + if (addedUncheckedTasks > 0) { + docTasksTotal += addedUncheckedTasks; + } + + updateBatchStateAndBroadcast(sessionId, prev => { + const prevState = prev[sessionId]; + const nextTotalAcrossAllDocs = Math.max(0, prevState.totalTasksAcrossAllDocs + addedUncheckedTasks); + const nextTotalTasks = Math.max(0, prevState.totalTasks + addedUncheckedTasks); + return { + ...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 let shortSummary = `[${docEntry.filename}] Task completed`; @@ -977,6 +1005,7 @@ ${docList} break; // Break out of the inner while loop for this document } + docCheckedCount = newCheckedCount; remainingTasks = newRemainingTasks; console.log(`[BatchProcessor] Document ${docEntry.filename}: ${remainingTasks} tasks remaining`); diff --git a/src/renderer/hooks/useBatchedSessionUpdates.ts b/src/renderer/hooks/useBatchedSessionUpdates.ts index 968e3159..eb46ef4e 100644 --- a/src/renderer/hooks/useBatchedSessionUpdates.ts +++ b/src/renderer/hooks/useBatchedSessionUpdates.ts @@ -305,19 +305,20 @@ export function useBatchedSessionUpdates( // Session-level usage const sessionUsageDelta = acc.usageDeltas.get(null); if (sessionUsageDelta) { - const existing = updatedSession.usageStats; - updatedSession = { - ...updatedSession, - usageStats: { - inputTokens: (existing?.inputTokens || 0) + sessionUsageDelta.inputTokens, - outputTokens: (existing?.outputTokens || 0) + sessionUsageDelta.outputTokens, - cacheReadInputTokens: (existing?.cacheReadInputTokens || 0) + sessionUsageDelta.cacheReadInputTokens, - cacheCreationInputTokens: (existing?.cacheCreationInputTokens || 0) + sessionUsageDelta.cacheCreationInputTokens, - totalCostUsd: (existing?.totalCostUsd || 0) + sessionUsageDelta.totalCostUsd, - contextWindow: sessionUsageDelta.contextWindow + const existing = updatedSession.usageStats; + updatedSession = { + ...updatedSession, + usageStats: { + inputTokens: (existing?.inputTokens || 0) + sessionUsageDelta.inputTokens, + outputTokens: (existing?.outputTokens || 0) + sessionUsageDelta.outputTokens, + cacheReadInputTokens: (existing?.cacheReadInputTokens || 0) + sessionUsageDelta.cacheReadInputTokens, + cacheCreationInputTokens: (existing?.cacheCreationInputTokens || 0) + sessionUsageDelta.cacheCreationInputTokens, + totalCostUsd: (existing?.totalCostUsd || 0) + sessionUsageDelta.totalCostUsd, + reasoningTokens: (existing?.reasoningTokens || 0) + (sessionUsageDelta.reasoningTokens || 0), + contextWindow: sessionUsageDelta.contextWindow + } + }; } - }; - } // Tab-level usage if (updatedSession.aiTabs) { @@ -335,8 +336,9 @@ export function useBatchedSessionUpdates( cacheReadInputTokens: tabUsageDelta.cacheReadInputTokens, cacheCreationInputTokens: tabUsageDelta.cacheCreationInputTokens, contextWindow: tabUsageDelta.contextWindow, - outputTokens: (existing?.outputTokens || 0) + tabUsageDelta.outputTokens, - totalCostUsd: (existing?.totalCostUsd || 0) + tabUsageDelta.totalCostUsd + outputTokens: tabUsageDelta.outputTokens, // Current (not accumulated) + totalCostUsd: (existing?.totalCostUsd || 0) + tabUsageDelta.totalCostUsd, + reasoningTokens: tabUsageDelta.reasoningTokens } }; }) @@ -490,8 +492,9 @@ export function useBatchedSessionUpdates( cacheReadInputTokens: usage.cacheReadInputTokens, cacheCreationInputTokens: usage.cacheCreationInputTokens, contextWindow: usage.contextWindow, - outputTokens: existing.outputTokens + usage.outputTokens, - totalCostUsd: existing.totalCostUsd + usage.totalCostUsd + outputTokens: usage.outputTokens, + totalCostUsd: existing.totalCostUsd + usage.totalCostUsd, + reasoningTokens: usage.reasoningTokens }); } else { // Session-level: all values are accumulated @@ -501,6 +504,7 @@ export function useBatchedSessionUpdates( cacheReadInputTokens: existing.cacheReadInputTokens + usage.cacheReadInputTokens, cacheCreationInputTokens: existing.cacheCreationInputTokens + usage.cacheCreationInputTokens, totalCostUsd: existing.totalCostUsd + usage.totalCostUsd, + reasoningTokens: (existing.reasoningTokens || 0) + (usage.reasoningTokens || 0), contextWindow: usage.contextWindow }); } diff --git a/src/types/vite-raw.d.ts b/src/types/vite-raw.d.ts new file mode 100644 index 00000000..88d404d0 --- /dev/null +++ b/src/types/vite-raw.d.ts @@ -0,0 +1,4 @@ +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/tsconfig.lint.json b/tsconfig.lint.json new file mode 100644 index 00000000..7b2e1f36 --- /dev/null +++ b/tsconfig.lint.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": [ + "src/renderer", + "src/shared", + "src/web", + "src/types" + ] +} diff --git a/vitest.config.mts b/vitest.config.mts index 801b3f44..bf22224f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -9,7 +9,14 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./src/__tests__/setup.ts'], 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, hookTimeout: 10000, teardownTimeout: 5000,