diff --git a/package.json b/package.json index 9bbf9d21c..54e5617f1 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "name": "Emdash Contributors", "email": "support@emdash.sh" }, + "homepage": "https://emdash.sh", "engines": { "node": ">=20.0.0 <23.0.0", "pnpm": ">=10.28.0" diff --git a/src/main/services/hostPreviewService.ts b/src/main/services/hostPreviewService.ts index adf5c9733..75a1a20e7 100644 --- a/src/main/services/hostPreviewService.ts +++ b/src/main/services/hostPreviewService.ts @@ -1,6 +1,8 @@ import { EventEmitter } from 'node:events'; import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; import net from 'node:net'; +import http from 'node:http'; +import https from 'node:https'; import fs from 'node:fs'; import path from 'node:path'; import { log } from '../lib/logger'; @@ -140,9 +142,7 @@ class HostPreviewService extends EventEmitter { // If process exists, verify it's running from the correct directory if (existingProc) { // Check if process is still running - try { - // On Unix, signal 0 checks if process exists - existingProc.kill(0); + if (existingProc.exitCode === null && existingProc.signalCode === null) { // Process is still running - check if cwd matches if (existingCwd && path.resolve(existingCwd) === cwd) { log.info?.('[hostPreview] reusing existing process', { @@ -163,7 +163,7 @@ class HostPreviewService extends EventEmitter { this.procs.delete(taskId); this.procCwds.delete(taskId); } - } catch { + } else { // Process has exited - clean up this.procs.delete(taskId); this.procCwds.delete(taskId); @@ -181,6 +181,7 @@ class HostPreviewService extends EventEmitter { const parentExists = fs.existsSync(parentNm); if (!wsExists && parentExists) { try { + // On Windows, 'junction' avoids needing administrator privileges const linkType = process.platform === 'win32' ? 'junction' : 'dir'; fs.symlinkSync(parentNm, wsNm, linkType as any); log.info?.('[hostPreview] linked node_modules', { @@ -253,7 +254,16 @@ class HostPreviewService extends EventEmitter { }); this.emit('event', { type: 'setup', taskId, status: 'done' } as HostPreviewEvent); } - } catch {} + } catch (e) { + this.emit('event', { + type: 'setup', + taskId, + status: 'error', + error: e instanceof Error ? e.message : String(e), + } as HostPreviewEvent); + log.error?.('[hostPreview] auto-install failed', e); + return { ok: false, error: `Auto-install failed: ${e}` }; + } // Choose a free port (avoid 3000) const preferred = [5173, 5174, 3001, 3002, 8080, 4200, 5500, 7000]; @@ -338,27 +348,28 @@ class HostPreviewService extends EventEmitter { const port = Number(parsed.port || 0); if (!port) return; - // Quick TCP probe to verify server is ready - const socket = net.createConnection({ host, port }, () => { - try { - socket.destroy(); - } catch {} - if (!urlEmitted) { - urlEmitted = true; - try { - this.emit('event', { - type: 'url', - taskId, - url: urlToProbe, - } as HostPreviewEvent); - } catch {} + // Quick HTTP probe to verify server is ready + const client = parsed.protocol === 'https:' ? https : http; + const req = client.request( + urlToProbe, + { method: 'HEAD', timeout: 500, rejectUnauthorized: false }, + (res) => { + if (!urlEmitted) { + urlEmitted = true; + try { + this.emit('event', { + type: 'url', + taskId, + url: urlToProbe, + } as HostPreviewEvent); + } catch {} + } } + ); + req.on('error', () => { + // Server not ready yet }); - socket.on('error', () => { - try { - socket.destroy(); - } catch {} - }); + req.end(); } catch {} }; @@ -378,38 +389,37 @@ class HostPreviewService extends EventEmitter { child.stderr.on('data', onData); // Probe periodically; if reachable and not emitted from logs, synthesize URL - const host = 'localhost'; const probeInterval = setInterval(() => { - if (urlEmitted) return; + if (urlEmitted) { + clearInterval(probeInterval); + return; + } // If we have a candidate URL from logs, probe that first if (candidateUrl) { probeAndEmitUrl(candidateUrl); return; } // Otherwise, probe the expected port - const socket = net.createConnection( - { host, port: Number(env.PORT) || forcedPort }, - () => { - try { - socket.destroy(); - } catch {} + const expectedPort = Number(env.PORT) || forcedPort; + const req = http.request( + `http://localhost:${expectedPort}`, + { method: 'HEAD', timeout: 500 }, + (res) => { if (!urlEmitted) { urlEmitted = true; + clearInterval(probeInterval); try { this.emit('event', { type: 'url', taskId, - url: `http://localhost:${Number(env.PORT) || forcedPort}`, + url: `http://localhost:${expectedPort}`, } as HostPreviewEvent); } catch {} } } ); - socket.on('error', () => { - try { - socket.destroy(); - } catch {} - }); + req.on('error', () => {}); + req.end(); }, 800); child.on('exit', async () => { @@ -431,8 +441,10 @@ class HostPreviewService extends EventEmitter { if (idx >= 0 && idx + 1 < args.length) args[idx + 1] = String(forcedPort); else if (idxPort >= 0 && idxPort + 1 < args.length) args[idxPort + 1] = String(forcedPort); - else if (pm === 'npm') args.push('--', '-p', String(forcedPort)); - else args.push('-p', String(forcedPort)); + else if (pm === 'npm') { + if (!args.includes('--')) args.push('--'); + args.push('-p', String(forcedPort)); + } else args.push('-p', String(forcedPort)); log.info?.('[hostPreview] retry on new port', { taskId, port: forcedPort, diff --git a/src/main/services/ptyIpc.ts b/src/main/services/ptyIpc.ts index 4492d739e..2fb10bf07 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -20,6 +20,7 @@ import { clearStoredSession, getStoredResumeTarget, markCodexSessionBound, + normalizeClaudeProjectPath, } from './ptyManager'; import { log } from '../lib/logger'; import { terminalSnapshotService } from './TerminalSnapshotService'; @@ -803,8 +804,7 @@ export function registerPtyIpc(): void { const claudeHashDir = path.join(os.homedir(), '.claude', 'projects', cwdHash); // Also check for path-based directory name (Claude's actual format) - // Replace path separators with hyphens for the directory name - const pathBasedName = cwd.replace(/\//g, '-'); + const pathBasedName = normalizeClaudeProjectPath(cwd); const claudePathDir = path.join(os.homedir(), '.claude', 'projects', pathBasedName); // Check if any Claude session directory exists for this working directory diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index 1c7cdba1c..46476287d 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -501,9 +501,19 @@ export function getStoredResumeTarget( return normalized.target; } +/** + * Encode a project working directory for use in Claude's ~/.claude/projects/ path. + * + * Claude encodes project paths by replacing path separators with hyphens. + * On Windows also strips ':' drive letters, and replaces backslashes. + */ +export function normalizeClaudeProjectPath(cwd: string): string { + return cwd.replace(/[:\\/]/g, '-'); +} + function claudeSessionFileExists(uuid: string, cwd: string): boolean { try { - const encoded = cwd.replace(/[:\\/]/g, '-'); + const encoded = normalizeClaudeProjectPath(cwd); const sessionFile = path.join(os.homedir(), '.claude', 'projects', encoded, `${uuid}.jsonl`); return fs.existsSync(sessionFile); } catch { @@ -523,8 +533,7 @@ function claudeSessionFileExists(uuid: string, cwd: string): boolean { */ function discoverExistingClaudeSession(cwd: string, excludeUuids: Set): string | null { try { - // Claude encodes project paths by replacing path separators; on Windows also strip ':'. - const encoded = cwd.replace(/[:\\/]/g, '-'); + const encoded = normalizeClaudeProjectPath(cwd); const projectDir = path.join(os.homedir(), '.claude', 'projects', encoded); if (!fs.existsSync(projectDir)) return null; @@ -627,9 +636,17 @@ export function applySessionIsolation( } } + const pushCreateOrResumeArg = (uuid: string) => { + if (provider.id === 'claude' && claudeSessionFileExists(uuid, cwd)) { + cliArgs.push('--resume', uuid); + } else { + cliArgs.push(provider.sessionIdFlag!, uuid); + } + markClaudeSessionCreated(id, uuid, cwd); + }; + if (isAdditionalChat) { - cliArgs.push(provider.sessionIdFlag, sessionUuid); - markClaudeSessionCreated(id, sessionUuid, cwd); + pushCreateOrResumeArg(sessionUuid); return true; } @@ -639,11 +656,9 @@ export function applySessionIsolation( const otherUuids = getOtherSessionUuids(id, parsed.providerId, cwd); const existingSession = discoverExistingClaudeSession(cwd, otherUuids); if (existingSession) { - cliArgs.push(provider.sessionIdFlag, existingSession); - markClaudeSessionCreated(id, existingSession, cwd); + pushCreateOrResumeArg(existingSession); } else { - cliArgs.push(provider.sessionIdFlag, sessionUuid); - markClaudeSessionCreated(id, sessionUuid, cwd); + pushCreateOrResumeArg(sessionUuid); } return true; } @@ -651,8 +666,7 @@ export function applySessionIsolation( if (!isResume) { // First-time creation — proactively assign a session ID so we can // reliably resume later if more chats of this provider are added. - cliArgs.push(provider.sessionIdFlag, sessionUuid); - markClaudeSessionCreated(id, sessionUuid, cwd); + pushCreateOrResumeArg(sessionUuid); return true; } diff --git a/src/main/services/ssh/SshService.ts b/src/main/services/ssh/SshService.ts index 6ae59be2e..710d9a20d 100644 --- a/src/main/services/ssh/SshService.ts +++ b/src/main/services/ssh/SshService.ts @@ -212,12 +212,37 @@ export class SshService extends EventEmitter { const sock = new Duplex({ read() {}, write(chunk, encoding, callback) { - return proxyProc.stdin!.write(chunk, encoding, callback); + if (!proxyProc.stdin || proxyProc.stdin.destroyed) { + callback(new Error('Proxy stdin destroyed')); + return false; + } + try { + return proxyProc.stdin.write(chunk, encoding, callback); + } catch (err: any) { + callback(err); + return false; + } }, final(callback) { - proxyProc.stdin!.end(callback); + if (!proxyProc.stdin || proxyProc.stdin.destroyed) { + callback(); + return; + } + try { + proxyProc.stdin.end(callback); + } catch (err: any) { + callback(err); + } }, }); + + proxyProc.stdin!.on('error', (err: any) => { + if (err.code !== 'EPIPE') { + sock.destroy(err); + } else { + console.warn('SshService proxy stdin received EPIPE, ignoring to prevent app crash'); + } + }); proxyProc.stdout!.on('data', (data) => sock.push(data)); proxyProc.stdout!.on('close', () => sock.push(null)); proxyProc.on('error', (err) => sock.destroy(err)); diff --git a/src/renderer/components/AIReviewConfigModal.tsx b/src/renderer/components/AIReviewConfigModal.tsx new file mode 100644 index 000000000..ecbe125eb --- /dev/null +++ b/src/renderer/components/AIReviewConfigModal.tsx @@ -0,0 +1,159 @@ +import React, { useState } from 'react'; +import { + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Label } from './ui/label'; +import { RadioGroup, RadioGroupItem } from './ui/radio-group'; +import { AgentDropdown } from './AgentDropdown'; +import { BaseModalProps, useModalContext } from '@/contexts/ModalProvider'; +import type { Agent } from '../types'; +import { agentConfig } from '../lib/agentConfig'; +import type { AIReviewConfig, ReviewDepth, ReviewType, AIReviewResult } from '@shared/reviewPreset'; +import { REVIEW_DEPTH_AGENTS } from '@shared/reviewPreset'; +import { launchReviewAgents } from '@/lib/aiReview'; +import { useToast } from '@/hooks/use-toast'; + +interface AIReviewConfigModalProps { + taskId: string; + taskPath: string; + availableAgents?: Agent[]; + installedAgents: string[]; +} + +export type AIReviewConfigModalOverlayProps = BaseModalProps & + AIReviewConfigModalProps; + +export function AIReviewConfigModalOverlay({ + taskId, + taskPath, + availableAgents = [], + installedAgents, + onSuccess, + onClose, +}: AIReviewConfigModalOverlayProps) { + const [depth, setDepth] = useState('quick'); + const [providerId, setProviderId] = useState('claude'); + const [isStarting, setIsStarting] = useState(false); + const [error, setError] = useState(null); + + const { toast } = useToast(); + + const { showModal } = useModalContext(); + + // If no installed agents provided, use all agents from agentConfig + const effectiveInstalledAgents = + installedAgents.length > 0 ? installedAgents : Object.keys(agentConfig); + const effectiveAvailableAgents = + availableAgents.length > 0 ? availableAgents : Object.keys(agentConfig); + + const handleStartReview = async () => { + if (!providerId) { + setError('Please select a provider'); + return; + } + + setIsStarting(true); + setError(null); + + const config: AIReviewConfig = { + depth, + reviewType: 'file-changes', + providerId: providerId as AIReviewConfig['providerId'], + }; + + try { + // Launch review agents + const { reviewId, conversationIds } = await launchReviewAgents(config, taskId, taskPath); + + // Close config modal + onSuccess(config); + + toast({ + title: 'Review Started', + description: + 'AI Review is analyzing the requested scope. You will be notified when complete.', + }); + + // Poll for results in background + } catch (err) { + console.error('Failed to start review:', err); + setError(err instanceof Error ? err.message : 'Failed to start review'); + setIsStarting(false); + } + }; + + const depthLabels: Record = { + quick: { label: 'Quick', description: `${REVIEW_DEPTH_AGENTS.quick} agent` }, + focused: { label: 'Focused', description: `${REVIEW_DEPTH_AGENTS.focused} agents` }, + comprehensive: { + label: 'Comprehensive', + description: `${REVIEW_DEPTH_AGENTS.comprehensive} agents`, + }, + }; + + return ( + + + AI Review + + Configure review settings. The review will analyze your changes and provide structured + feedback. + + + +
+ {/* Review Depth */} +
+ + setDepth(v as ReviewDepth)} + className="space-y-2" + > + {(Object.keys(depthLabels) as ReviewDepth[]).map((d) => ( +
+ + +
+ ))} +
+
+ + {/* Provider Selection */} +
+ + +

Agent used to perform the review

+
+ + {error &&

{error}

} +
+ + + + + +
+ ); +} diff --git a/src/renderer/components/FileChangesPanel.tsx b/src/renderer/components/FileChangesPanel.tsx index 2bfdaa3a7..2885a6c90 100644 --- a/src/renderer/components/FileChangesPanel.tsx +++ b/src/renderer/components/FileChangesPanel.tsx @@ -31,6 +31,7 @@ import { CheckCircle2, XCircle, GitMerge, + Sparkles, } from 'lucide-react'; import { AlertDialog, @@ -45,6 +46,7 @@ import { import { useTaskScope } from './TaskScopeContext'; import { fetchPrBaseDiff, parseDiffToFileChanges } from '../lib/parsePrDiff'; import { formatDiffCount } from '../lib/gitChangePresentation'; +import { useModalContext } from '../contexts/ModalProvider'; type ActiveTab = 'changes' | 'checks'; type PrMode = 'create' | 'draft' | 'merge'; @@ -257,6 +259,22 @@ const FileChangesPanelComponent: React.FC = ({ const [branchAhead, setBranchAhead] = useState(null); const [branchStatusLoading, setBranchStatusLoading] = useState(false); + // AI Review modal + const { showModal } = useModalContext(); + + const handleOpenAIReview = useCallback(() => { + if (!resolvedTaskId || !safeTaskPath) return; + showModal('aiReviewConfigModal', { + taskId: resolvedTaskId, + taskPath: safeTaskPath, + installedAgents: [], + onSuccess: (config) => { + // Review configuration selected - the results modal will be shown by the caller + console.log('AI Review config:', config); + }, + }); + }, [resolvedTaskId, safeTaskPath, showModal]); + // Reset action loading states when task changes useEffect(() => { setIsMergingToMain(false); @@ -651,6 +669,16 @@ const FileChangesPanelComponent: React.FC = ({ Changes )} + ) : hasChanges ? (
@@ -703,6 +731,16 @@ const FileChangesPanelComponent: React.FC = ({ )} )} + >; diff --git a/src/renderer/lib/aiReview.ts b/src/renderer/lib/aiReview.ts new file mode 100644 index 000000000..7d17c29a0 --- /dev/null +++ b/src/renderer/lib/aiReview.ts @@ -0,0 +1,356 @@ +import type { Agent } from '../types'; +import type { + AIReviewConfig, + AIReviewIssue, + AIReviewResult, + ReviewDepth, + ReviewMessage, +} from '@shared/reviewPreset'; +import { REVIEW_DEPTH_AGENTS, REVIEW_PROMPTS } from '@shared/reviewPreset'; +import { rpc } from './rpc'; +import { makePtyId } from '@shared/ptyId'; +import type { ProviderId } from '@shared/providers/registry'; +import { buildReviewConversationMetadata } from '@shared/reviewPreset'; +import { terminalSessionRegistry } from '../terminal/SessionRegistry'; + +const CONVERSATIONS_CHANGED_EVENT = 'emdash:conversations-changed'; + +function generateId(): string { + return Math.random().toString(36).substring(2, 15); +} + +export async function captureTerminalSnapshot(ptyId: string): Promise { + // First try to grab the active session buffer to prevent failure if it hasn't written a snapshot to disk yet + const activeSession = terminalSessionRegistry.getSession(ptyId); + if (activeSession) { + const data = activeSession.getSnapshotData(); + if (data) { + return data; + } + } + + const response = await window.electronAPI.ptyGetSnapshot({ id: ptyId }); + if (!response?.ok || !response.snapshot?.data) { + throw new Error( + 'Failed to capture terminal snapshot. Please make sure the terminal has produced output.' + ); + } + return response.snapshot.data; +} + +function buildReviewPrompt(reviewType: string, depth: ReviewDepth, content?: string): string { + const templates = REVIEW_PROMPTS[reviewType as keyof typeof REVIEW_PROMPTS]; + const template = templates?.[depth]; + if (!template) { + // Fallback to fileChanges if reviewType not found, or throw + const fallback = REVIEW_PROMPTS.fileChanges[depth]; + if (!fallback) { + throw new Error(`No review prompt found for ${reviewType}/${depth}`); + } + if (content) { + return `${fallback}\n\n--- Content to review ---\n${content}`; + } + return fallback; + } + if (content) { + return `${template}\n\n--- Content to review ---\n${content}`; + } + return template; +} + +export async function startReviewAgentPty(args: { + taskId: string; + taskPath: string; + conversationId: string; + providerId: ProviderId; + initialPrompt: string; +}): Promise<{ ptyId: string; started: boolean }> { + const ptyId = makePtyId(args.providerId, 'chat', args.conversationId); + + const result = await window.electronAPI.ptyStartDirect({ + id: ptyId, + providerId: args.providerId, + cwd: args.taskPath, + cols: 120, + rows: 40, + initialPrompt: args.initialPrompt, + env: {}, + resume: false, + }); + + if (!result?.ok) { + throw new Error(result?.error || 'Failed to start PTY'); + } + + return { ptyId, started: true }; +} + +export async function launchReviewAgent(args: { + taskId: string; + taskPath: string; + reviewId: string; + agent: Agent; + prompt: string; +}): Promise<{ conversationId: string; ptyId: string }> { + const conversation = await rpc.db.createConversation({ + taskId: args.taskId, + title: `Review ${args.reviewId.slice(0, 8)}`, + provider: args.agent, + isMain: false, + metadata: buildReviewConversationMetadata(args.prompt), + }); + + window.dispatchEvent( + new CustomEvent(CONVERSATIONS_CHANGED_EVENT, { + detail: { taskId: args.taskId, conversationId: conversation.id }, + }) + ); + + // Start the PTY for this review agent + const { ptyId } = await startReviewAgentPty({ + taskId: args.taskId, + taskPath: args.taskPath, + conversationId: conversation.id, + providerId: args.agent as ProviderId, + initialPrompt: args.prompt, + }); + + return { conversationId: conversation.id, ptyId }; +} + +export async function launchReviewAgents( + config: AIReviewConfig, + taskId: string, + taskPath: string, + content?: string +): Promise<{ reviewId: string; conversationIds: string[]; ptyIds: string[] }> { + const reviewId = generateId(); + const agentCount = REVIEW_DEPTH_AGENTS[config.depth]; + + // Launch all agents in parallel using Promise.allSettled to handle partial failures + const prompt = buildReviewPrompt(config.reviewType, config.depth, content); + const launches = Array.from({ length: agentCount }, () => + launchReviewAgent({ + taskId, + taskPath, + reviewId, + agent: config.providerId, + prompt, + }) + ); + + const results = await Promise.allSettled(launches); + + const conversationIds: string[] = []; + const ptyIds: string[] = []; + const failures: string[] = []; + + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + conversationIds.push(result.value.conversationId); + ptyIds.push(result.value.ptyId); + } else { + failures.push(`Agent ${i + 1}: ${result.reason?.message || String(result.reason)}`); + } + }); + + // If all agents failed, throw an error + if (conversationIds.length === 0) { + throw new Error(`All ${agentCount} agent launches failed: ${failures.join('; ')}`); + } + + // If some agents failed, log a warning (successful agents will still be used) + if (failures.length > 0) { + console.warn( + `Some agent launches failed: ${failures.join('; ')}. Proceeding with ${conversationIds.length} successful agents.` + ); + } + + return { reviewId, conversationIds, ptyIds }; +} + +export async function pollReviewMessages( + conversationId: string, + sinceTimestamp?: string +): Promise<{ messages: ReviewMessage[]; hasNewMessages: boolean }> { + const messages = await rpc.db.getMessages(conversationId); + const since = sinceTimestamp ? new Date(sinceTimestamp) : new Date(0); + const newMessages = messages.filter((m) => new Date(m.timestamp) > since); + return { + messages, + hasNewMessages: newMessages.length > 0, + }; +} + +export function parseReviewMessages(messages: ReviewMessage[]): AIReviewIssue[] { + // Find the most recent agent message that contains review results + const agentMessages = messages.filter((m) => m.sender === 'agent').reverse(); + + for (const msg of agentMessages) { + const issues = tryParseIssues(msg.content); + if (issues.length > 0) { + return issues; + } + } + + return []; +} + +function tryParseIssues(content: string): AIReviewIssue[] { + // Try to parse as JSON first + try { + const parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + return parsed.map((item, index) => normalizeIssue(item, index)); + } + if (parsed.issues && Array.isArray(parsed.issues)) { + return parsed.issues.map((item: unknown, index: number) => normalizeIssue(item, index)); + } + } catch { + // Not JSON, try markdown parsing + } + + // Fallback: parse from markdown format + return parseMarkdownIssues(content); +} + +function normalizeIssue(item: unknown, index: number): AIReviewIssue { + const obj = item as Record; + return { + id: (obj.id as string) || generateId(), + severity: normalizeSeverity(obj.severity), + category: String(obj.category || obj.type || 'other'), + title: String(obj.title || obj.name || `Issue ${index + 1}`), + description: String(obj.description || obj.body || obj.content || ''), + codeSnapshot: (obj.codeSnapshot || obj.code || obj.snippet) as string | undefined, + filePath: (obj.filePath || obj.file || obj.path) as string | undefined, + lineRange: (obj.lineRange || obj.lines) as { start: number; end: number } | undefined, + fixPrompt: (obj.fixPrompt || obj.fix || obj.recommendation) as string | undefined, + }; +} + +function normalizeSeverity(severity: unknown): 'critical' | 'major' | 'minor' | 'info' { + const s = String(severity || '').toLowerCase(); + if (s === 'critical' || s === 'error' || s === 'blocker') return 'critical'; + if (s === 'major' || s === 'warning') return 'major'; + if (s === 'minor') return 'minor'; + if (s === 'info') return 'info'; + return 'info'; +} + +function parseMarkdownIssues(content: string): AIReviewIssue[] { + const issues: AIReviewIssue[] = []; + const lines = content.split('\n'); + let currentIssue: Partial | null = null; + let currentCodeBlock: string[] = []; + let inCodeBlock = false; + + for (const line of lines) { + // Handle code fences + if (line.startsWith('```')) { + if (inCodeBlock) { + // End of code block + if (currentIssue) { + currentIssue.codeSnapshot = currentCodeBlock.join('\n'); + } + currentCodeBlock = []; + inCodeBlock = false; + } else { + // Start of code block + inCodeBlock = true; + } + continue; + } + + // Look for issue headers like "## Issue:" or "### [CRITICAL]" or "- **Title**:" + const headerMatch = line.match(/^#{1,3}\s*\[?(\w+)\]?\s*[:\-]?\s*(.*)/i); + if (headerMatch) { + if (currentIssue && currentIssue.title) { + currentIssue.description = currentCodeBlock.join('\n') || currentIssue.description; + issues.push(currentIssue as AIReviewIssue); + } + const severity = normalizeSeverity(headerMatch[1]); + currentIssue = { + id: generateId(), + severity, + title: headerMatch[2].trim(), + description: '', + category: 'other', + }; + currentCodeBlock = []; + continue; + } + + // Check for bullet points with severity + const bulletMatch = line.match(/^[-*]\s*\*\*\[?(\w+)\]?\*\*[:\-]?\s*(.*)/i); + if (bulletMatch && !headerMatch) { + if (currentIssue && currentIssue.title) { + currentIssue.description = currentCodeBlock.join('\n') || currentIssue.description; + issues.push(currentIssue as AIReviewIssue); + } + currentIssue = { + id: generateId(), + severity: normalizeSeverity(bulletMatch[1]), + title: bulletMatch[2].trim(), + description: '', + category: 'other', + }; + currentCodeBlock = []; + continue; + } + + if (currentIssue) { + if (line.match(/^File:|Path:|Location:/i)) { + currentIssue.filePath = line.replace(/^File:|Path:|Location:\s*/i, '').trim(); + } else if (line.match(/^Category:|Type:/i)) { + currentIssue.category = line.replace(/^Category:|Type:\s*/i, '').trim(); + } else if (inCodeBlock || line.match(/^\s{2,}/)) { + currentCodeBlock.push(line); + } else if (line.trim()) { + currentIssue.description = (currentIssue.description || '') + line + '\n'; + } + } + } + + if (currentIssue && currentIssue.title) { + currentIssue.description = currentCodeBlock.join('\n') || currentIssue.description; + issues.push(currentIssue as AIReviewIssue); + } + + return issues; +} + +export async function aggregateReviewResults( + results: Array<{ conversationId: string; messages: ReviewMessage[] }>, + config: AIReviewConfig, + reviewId: string, + durationMs: number +): Promise { + const allIssues: AIReviewIssue[] = []; + + for (const result of results) { + const issues = parseReviewMessages(result.messages); + allIssues.push(...issues); + } + + // Sort by severity + const severityOrder = { critical: 0, major: 1, minor: 2, info: 3 }; + allIssues.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); + + const summary = `Found ${allIssues.length} issues: ${ + allIssues.filter((i) => i.severity === 'critical').length + } critical, ${allIssues.filter((i) => i.severity === 'major').length} major, ${ + allIssues.filter((i) => i.severity === 'minor').length + } minor, ${allIssues.filter((i) => i.severity === 'info').length} info`; + + return { + reviewId, + timestamp: new Date().toISOString(), + depth: config.depth, + reviewType: config.reviewType, + issues: allIssues, + summary, + durationMs, + agentIds: results.map((r) => r.conversationId), + }; +} diff --git a/src/renderer/lib/quickPreview.ts b/src/renderer/lib/quickPreview.ts index cac92c1d9..a087556a3 100644 --- a/src/renderer/lib/quickPreview.ts +++ b/src/renderer/lib/quickPreview.ts @@ -18,6 +18,7 @@ export async function ensureCompose(taskPath?: string): Promise { const candidates = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']; for (const file of candidates) { const res = await window.electronAPI.fsRead(taskPath, file, 1); + // Return early if docker-compose file already exists if (res?.success) return true; } } catch {} diff --git a/src/renderer/lib/reviewChat.ts b/src/renderer/lib/reviewChat.ts index 0a4f432df..af8a88d76 100644 --- a/src/renderer/lib/reviewChat.ts +++ b/src/renderer/lib/reviewChat.ts @@ -26,7 +26,9 @@ export function getReviewSettings(settings?: AppSettings): ReviewSettings { async function assertProviderInstalled(providerId: string): Promise { const result = await window.electronAPI.getProviderStatuses?.(); - if (!result?.success || !result.statuses) return; + if (!result?.success || !result.statuses) { + throw new Error('Failed to get provider statuses'); + } if (result.statuses[providerId]?.installed === true) return; throw new Error('Configured review agent is not installed'); } @@ -62,6 +64,10 @@ export async function createReviewConversation(args: { metadata: buildReviewConversationMetadata(prompt), }); + if (!conversation || !conversation.id) { + throw new Error('Failed to create review conversation'); + } + window.dispatchEvent( new CustomEvent(CONVERSATIONS_CHANGED_EVENT, { detail: { taskId: args.taskId, conversationId: conversation.id }, diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index fa44aaa5a..3017518f9 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -816,6 +816,10 @@ export class TerminalSessionManager { return this.ptyStarted; } + getSnapshotData(): string { + return this.serializeAddon.serialize() || ''; + } + async restart(): Promise { if (this.disposed || this.ptyStarted) return false; if (this.restartPromise) return this.restartPromise; diff --git a/src/shared/reviewPreset.ts b/src/shared/reviewPreset.ts index b89e99949..e442fa224 100644 --- a/src/shared/reviewPreset.ts +++ b/src/shared/reviewPreset.ts @@ -17,6 +17,96 @@ export interface ReviewConversationMetadata { initialPromptSent?: boolean | null; } +// AI Review types +export type ReviewDepth = 'quick' | 'focused' | 'comprehensive'; + +export const REVIEW_DEPTH_AGENTS: Record = { + quick: 1, + focused: 3, + comprehensive: 5, +}; + +export type ReviewType = 'file-changes'; + +// Message type for review conversations (shared between renderer and main) +export interface ReviewMessage { + id: string; + conversationId: string; + sender: 'user' | 'agent' | 'system'; + content: string; + timestamp: string; +} + +export interface AIReviewConfig { + depth: ReviewDepth; + reviewType: ReviewType; + providerId: ProviderId; +} + +export interface AIReviewIssue { + id: string; + severity: 'critical' | 'major' | 'minor' | 'info'; + category: string; + title: string; + description: string; + codeSnapshot?: string; + filePath?: string; + lineRange?: { start: number; end: number }; + fixPrompt?: string; +} + +export interface AIReviewResult { + reviewId: string; + timestamp: string; + depth: ReviewDepth; + reviewType: ReviewType; + issues: AIReviewIssue[]; + summary: string; + durationMs: number; + agentIds: string[]; // Conversation IDs of review agents +} + +// Review prompt templates +// Output format: JSON array of issues with schema: +// [{ "severity": "critical|major|minor|info", "category": "string", "title": "string", "description": "string", "filePath": "string?", "lineRange": {"start": number, "end": number}?, "codeSnapshot": "string?", "fixPrompt": "string?" }] +// Return only valid JSON. +export const REVIEW_PROMPTS = { + fileChanges: { + quick: `You are a code reviewer. Review the diff between the task's source branch and the current workspace for: +- Critical bugs and security issues +- Obvious correctness problems +- Major performance concerns + +Provide your review as a JSON array of issues with this schema: +[{ "severity": "critical|major|minor|info", "category": "string", "title": "string", "description": "string", "filePath": "string?", "lineRange": {"start": number, "end": number}?, "codeSnapshot": "string?", "fixPrompt": "string?" }] +Return only valid JSON.`, + focused: `You are a thorough code reviewer. Review the diff between the task's source branch and the current workspace for: +- Correctness, edge cases, and regressions +- Security vulnerabilities +- Performance issues +- Error handling problems +- Testing gaps +- Code maintainability + +Provide your review as a JSON array of issues with this schema: +[{ "severity": "critical|major|minor|info", "category": "string", "title": "string", "description": "string", "filePath": "string?", "lineRange": {"start": number, "end": number}?, "codeSnapshot": "string?", "fixPrompt": "string?" }] +Return only valid JSON.`, + comprehensive: `You are an expert code reviewer conducting a comprehensive review. Review diff between the task's source branch and the current workspace for: +- All correctness issues including edge cases +- Security (OWASP top 10, injection, auth issues) +- Performance bottlenecks and algorithmic improvements +- Error handling and fault tolerance +- Testing coverage and quality +- Maintainability and readability +- Best practices adherence +- Potential bugs and race conditions + +Provide your review as a JSON array of issues with this schema: +[{ "severity": "critical|major|minor|info", "category": "string", "title": "string", "description": "string", "filePath": "string?", "lineRange": {"start": number, "end": number}?, "codeSnapshot": "string?", "fixPrompt": "string?" }] +Return only valid JSON.`, + }, +}; + export function parseConversationMetadata( metadata?: string | null ): Record | null {