From 6481b73c03010f363bd23d451956ed3354d48cf9 Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Sun, 5 Apr 2026 08:28:38 +0800 Subject: [PATCH 1/8] fix: add homepage field for linux build --- package.json | 1 + 1 file changed, 1 insertion(+) 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" From 1ced66cfde9808e2624b8e4b761540471603dcbf Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Sun, 5 Apr 2026 11:48:27 +0800 Subject: [PATCH 2/8] fix(ssh): handle EPIPE error in SshService proxy command to prevent app crash --- src/main/services/ssh/SshService.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) 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)); From 3509789951891d2a5b4406bb51ef7013798b2d0f Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Sat, 4 Apr 2026 23:18:00 +0800 Subject: [PATCH 3/8] feat: add AI review feature for code changes --- src/main/services/ptyManager.ts | 23 +- .../components/AIReviewConfigModal.tsx | 269 +++++++++++++++ .../components/AIReviewResultsModal.tsx | 280 ++++++++++++++++ src/renderer/components/FileChangesPanel.tsx | 38 +++ src/renderer/contexts/ModalProvider.tsx | 4 + src/renderer/lib/aiReview.ts | 316 ++++++++++++++++++ .../terminal/TerminalSessionManager.ts | 4 + src/shared/reviewPreset.ts | 72 ++++ 8 files changed, 997 insertions(+), 9 deletions(-) create mode 100644 src/renderer/components/AIReviewConfigModal.tsx create mode 100644 src/renderer/components/AIReviewResultsModal.tsx create mode 100644 src/renderer/lib/aiReview.ts diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index 1c7cdba1c..0a8d097e7 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -503,7 +503,7 @@ export function getStoredResumeTarget( function claudeSessionFileExists(uuid: string, cwd: string): boolean { try { - const encoded = cwd.replace(/[:\\/]/g, '-'); + const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); const sessionFile = path.join(os.homedir(), '.claude', 'projects', encoded, `${uuid}.jsonl`); return fs.existsSync(sessionFile); } catch { @@ -627,9 +627,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 +647,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 +657,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/renderer/components/AIReviewConfigModal.tsx b/src/renderer/components/AIReviewConfigModal.tsx new file mode 100644 index 000000000..8a0a6a8a0 --- /dev/null +++ b/src/renderer/components/AIReviewConfigModal.tsx @@ -0,0 +1,269 @@ +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, pollReviewMessages, aggregateReviewResults } 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 + pollForResults(reviewId, conversationIds, config, 0); + } catch (err) { + console.error('Failed to start review:', err); + setError(err instanceof Error ? err.message : 'Failed to start review'); + setIsStarting(false); + } + }; + + async function pollForResults( + reviewId: string, + conversationIds: string[], + config: AIReviewConfig, + pollCount: number + ) { + const maxPolls = 120; // 2 minutes with 1s interval + const pollInterval = 1000; + + if (pollCount >= maxPolls) { + // Timeout - show partial results + try { + const results = await collectResults(conversationIds, config, reviewId); + showResultsModal(results); + } catch { + // Ignore errors on timeout + } + return; + } + + try { + // Check if we have any responses from review agents + let hasResponses = false; + for (const convId of conversationIds) { + const { messages } = await pollReviewMessages(convId); + if (messages.some((m) => m.sender === 'agent' && m.content.length > 50)) { + hasResponses = true; + break; + } + } + + if (hasResponses) { + const results = await collectResults(conversationIds, config, reviewId); + showResultsModal(results); + return; + } + } catch { + // Continue polling on error + } + + // Schedule next poll + setTimeout(() => { + pollForResults(reviewId, conversationIds, config, pollCount + 1); + }, pollInterval); + } + + async function collectResults( + conversationIds: string[], + config: AIReviewConfig, + reviewId: string + ): Promise { + const results: AIReviewResult[] = []; + const startTime = Date.now(); + + for (const convId of conversationIds) { + try { + const { messages } = await pollReviewMessages(convId); + const durationMs = Date.now() - startTime; + const issues = messages + .filter((m) => m.sender === 'agent') + .flatMap((m) => { + try { + const parsed = JSON.parse(m.content); + if (Array.isArray(parsed)) { + return parsed; + } + if (parsed.issues && Array.isArray(parsed.issues)) { + return parsed.issues; + } + } catch { + // Not JSON, ignore + } + return []; + }); + + if (issues.length > 0 || messages.some((m) => m.sender === 'agent')) { + const aggregated = await aggregateReviewResults( + [{ conversationId: convId, messages }], + config, + reviewId, + durationMs + ); + results.push(aggregated); + } + } catch { + // Ignore errors for individual conversations + } + } + + return results; + } + + function showResultsModal(results: AIReviewResult[]) { + showModal('aiReviewResultsModal', { + results, + isLoading: false, + onRunAnotherReview: () => { + showModal('aiReviewConfigModal', { + taskId, + taskPath, + availableAgents, + installedAgents, + onSuccess: () => {}, + }); + }, + onClose: () => {}, + }); + } + + 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/AIReviewResultsModal.tsx b/src/renderer/components/AIReviewResultsModal.tsx new file mode 100644 index 000000000..71857c9df --- /dev/null +++ b/src/renderer/components/AIReviewResultsModal.tsx @@ -0,0 +1,280 @@ +import React, { useState } from 'react'; +import { DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { Spinner } from './ui/spinner'; +import { ScrollArea } from './ui/scroll-area'; +import { BaseModalProps } from '@/contexts/ModalProvider'; +import type { AIReviewResult, AIReviewIssue } from '@shared/reviewPreset'; +import { AlertCircle, CheckCircle, Code, FileText, Wrench, Clock, Users } from 'lucide-react'; + +interface AIReviewResultsModalProps { + results: AIReviewResult[]; + isLoading?: boolean; + onRunAnotherReview?: () => void; + onFixIssue?: (issue: AIReviewIssue, result: AIReviewResult) => void; +} + +export type AIReviewResultsModalOverlayProps = BaseModalProps & + AIReviewResultsModalProps & { + initialResults?: AIReviewResult[]; + }; + +export function AIReviewResultsModalOverlay({ + results: initialResults = [], + isLoading = false, + onRunAnotherReview, + onFixIssue, + onClose, +}: AIReviewResultsModalOverlayProps) { + const [activeTab, setActiveTab] = useState<'all' | number>('all'); + const [expandedIssues, setExpandedIssues] = useState>(new Set()); + + const allIssues = initialResults.flatMap((r) => r.issues); + const severityOrder = { critical: 0, major: 1, minor: 2, info: 3 }; + + const sortedIssues = [...allIssues].sort( + (a, b) => severityOrder[a.severity] - severityOrder[b.severity] + ); + + const toggleIssueExpanded = (issueId: string) => { + setExpandedIssues((prev) => { + const next = new Set(prev); + if (next.has(issueId)) { + next.delete(issueId); + } else { + next.add(issueId); + } + return next; + }); + }; + + const handleFix = (issue: AIReviewIssue, result: AIReviewResult) => { + onFixIssue?.(issue, result); + }; + + const severityConfig = { + critical: { + label: 'Critical', + color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', + icon: AlertCircle, + }, + major: { + label: 'Major', + color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', + icon: AlertCircle, + }, + minor: { + label: 'Minor', + color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300', + icon: AlertCircle, + }, + info: { + label: 'Info', + color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + icon: AlertCircle, + }, + }; + + const formatDuration = (ms: number) => { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + }; + + const totalIssues = allIssues.length; + const criticalCount = allIssues.filter((i) => i.severity === 'critical').length; + const majorCount = allIssues.filter((i) => i.severity === 'major').length; + + return ( + + +
+
+ AI Review Results + {initialResults.length > 0 && ( + + + {initialResults.length} agent{initialResults.length > 1 ? 's' : ''} + + )} +
+ {initialResults.length > 0 && ( +
+ + + {formatDuration(initialResults[0]?.durationMs || 0)} + + + + {totalIssues} issue{totalIssues !== 1 ? 's' : ''} + +
+ )} +
+ {(criticalCount > 0 || majorCount > 0) && ( +
+ {criticalCount > 0 && ( + + {criticalCount} critical + + )} + {majorCount > 0 && ( + {majorCount} major + )} +
+ )} +
+ + {isLoading ? ( +
+
+ +

Running AI review...

+
+
+ ) : allIssues.length === 0 ? ( +
+
+ +

No issues found

+

+ The review didn't find any significant issues. +

+
+
+ ) : ( + <> + {/* Tab bar */} + {initialResults.length > 1 && ( +
+ + {initialResults.map((result, index) => ( + + ))} +
+ )} + + {/* Issues list */} + +
+ {sortedIssues.map((issue) => { + const config = severityConfig[issue.severity]; + const SeverityIcon = config.icon; + const isExpanded = expandedIssues.has(issue.id); + + return ( +
+
+
+
+ + + {config.label} + +
+

{issue.title}

+ {issue.category && ( +

{issue.category}

+ )} +

+ {issue.description} +

+
+
+ {issue.codeSnapshot && ( + + )} +
+ + {/* File path and line range */} + {(issue.filePath || issue.lineRange) && ( +
+ {issue.filePath && {issue.filePath}} + {issue.lineRange && ( + + L{issue.lineRange.start}-L{issue.lineRange.end} + + )} +
+ )} + + {/* Code snapshot */} + {issue.codeSnapshot && isExpanded && ( +
+
+                            {issue.codeSnapshot}
+                          
+
+ )} + + {/* Actions */} + {issue.fixPrompt && ( +
+ +
+ )} +
+
+ ); + })} +
+
+ + )} + + + + + +
+ ); +} 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..4bb02a076 --- /dev/null +++ b/src/renderer/lib/aiReview.ts @@ -0,0 +1,316 @@ +import type { Agent } from '../types'; +import type { + AIReviewConfig, + AIReviewIssue, + AIReviewResult, + ReviewDepth, +} 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 type { Message } from '../../main/services/DatabaseService'; +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(depth: ReviewDepth, content?: string): string { + const key = 'fileChanges'; + const template = REVIEW_PROMPTS[key][depth]; + 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]; + const conversationIds: string[] = []; + const ptyIds: string[] = []; + + for (let i = 0; i < agentCount; i++) { + const prompt = buildReviewPrompt(config.depth, content); + const { conversationId, ptyId } = await launchReviewAgent({ + taskId, + taskPath, + reviewId, + agent: config.providerId, + prompt, + }); + conversationIds.push(conversationId); + ptyIds.push(ptyId); + } + + return { reviewId, conversationIds, ptyIds }; +} + +export async function pollReviewMessages( + conversationId: string, + sinceTimestamp?: string +): Promise<{ messages: Message[]; 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: Message[]): 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' || s === 'info') return 'minor'; + return 'info'; +} + +function parseMarkdownIssues(content: string): AIReviewIssue[] { + const issues: AIReviewIssue[] = []; + const lines = content.split('\n'); + let currentIssue: Partial | null = null; + let currentCodeBlock: string[] = []; + + for (const line of lines) { + // 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; + } + + // Accumulate code blocks + if (line.startsWith('```')) { + if (currentCodeBlock.length > 0) { + // End of code block + if (currentIssue) { + currentIssue.codeSnapshot = currentCodeBlock.join('\n'); + } + currentCodeBlock = []; + } + // Start of code block - skip the fence + 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 (currentCodeBlock.length > 0 || 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: Message[] }>, + 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/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..922160913 100644 --- a/src/shared/reviewPreset.ts +++ b/src/shared/reviewPreset.ts @@ -17,6 +17,78 @@ 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'; + +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 +export const REVIEW_PROMPTS = { + fileChanges: { + quick: `You are a code reviewer. Review the following file changes for: +- Critical bugs and security issues +- Obvious correctness problems +- Major performance concerns + +Provide your review in a structured format with specific issues found.`, + focused: `You are a thorough code reviewer. Review the following file changes for: +- Correctness, edge cases, and regressions +- Security vulnerabilities +- Performance issues +- Error handling problems +- Testing gaps +- Code maintainability + +Provide your review in a structured format with specific issues found.`, + comprehensive: `You are an expert code reviewer conducting a comprehensive review. Review the following file changes 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 in a structured format with specific issues found, including severity and category.`, + }, +}; + export function parseConversationMetadata( metadata?: string | null ): Record | null { From 8996988945de55a776343477969bc00749ec0e58 Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Sun, 5 Apr 2026 21:46:56 +0800 Subject: [PATCH 4/8] fix(ai-review): remove AI Review Results modal display --- .../components/AIReviewConfigModal.tsx | 112 +------ .../components/AIReviewResultsModal.tsx | 280 ------------------ src/renderer/contexts/ModalProvider.tsx | 2 - src/shared/reviewPreset.ts | 6 +- 4 files changed, 4 insertions(+), 396 deletions(-) delete mode 100644 src/renderer/components/AIReviewResultsModal.tsx diff --git a/src/renderer/components/AIReviewConfigModal.tsx b/src/renderer/components/AIReviewConfigModal.tsx index 8a0a6a8a0..ecbe125eb 100644 --- a/src/renderer/components/AIReviewConfigModal.tsx +++ b/src/renderer/components/AIReviewConfigModal.tsx @@ -15,7 +15,7 @@ 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, pollReviewMessages, aggregateReviewResults } from '@/lib/aiReview'; +import { launchReviewAgents } from '@/lib/aiReview'; import { useToast } from '@/hooks/use-toast'; interface AIReviewConfigModalProps { @@ -80,7 +80,6 @@ export function AIReviewConfigModalOverlay({ }); // Poll for results in background - pollForResults(reviewId, conversationIds, config, 0); } catch (err) { console.error('Failed to start review:', err); setError(err instanceof Error ? err.message : 'Failed to start review'); @@ -88,115 +87,6 @@ export function AIReviewConfigModalOverlay({ } }; - async function pollForResults( - reviewId: string, - conversationIds: string[], - config: AIReviewConfig, - pollCount: number - ) { - const maxPolls = 120; // 2 minutes with 1s interval - const pollInterval = 1000; - - if (pollCount >= maxPolls) { - // Timeout - show partial results - try { - const results = await collectResults(conversationIds, config, reviewId); - showResultsModal(results); - } catch { - // Ignore errors on timeout - } - return; - } - - try { - // Check if we have any responses from review agents - let hasResponses = false; - for (const convId of conversationIds) { - const { messages } = await pollReviewMessages(convId); - if (messages.some((m) => m.sender === 'agent' && m.content.length > 50)) { - hasResponses = true; - break; - } - } - - if (hasResponses) { - const results = await collectResults(conversationIds, config, reviewId); - showResultsModal(results); - return; - } - } catch { - // Continue polling on error - } - - // Schedule next poll - setTimeout(() => { - pollForResults(reviewId, conversationIds, config, pollCount + 1); - }, pollInterval); - } - - async function collectResults( - conversationIds: string[], - config: AIReviewConfig, - reviewId: string - ): Promise { - const results: AIReviewResult[] = []; - const startTime = Date.now(); - - for (const convId of conversationIds) { - try { - const { messages } = await pollReviewMessages(convId); - const durationMs = Date.now() - startTime; - const issues = messages - .filter((m) => m.sender === 'agent') - .flatMap((m) => { - try { - const parsed = JSON.parse(m.content); - if (Array.isArray(parsed)) { - return parsed; - } - if (parsed.issues && Array.isArray(parsed.issues)) { - return parsed.issues; - } - } catch { - // Not JSON, ignore - } - return []; - }); - - if (issues.length > 0 || messages.some((m) => m.sender === 'agent')) { - const aggregated = await aggregateReviewResults( - [{ conversationId: convId, messages }], - config, - reviewId, - durationMs - ); - results.push(aggregated); - } - } catch { - // Ignore errors for individual conversations - } - } - - return results; - } - - function showResultsModal(results: AIReviewResult[]) { - showModal('aiReviewResultsModal', { - results, - isLoading: false, - onRunAnotherReview: () => { - showModal('aiReviewConfigModal', { - taskId, - taskPath, - availableAgents, - installedAgents, - onSuccess: () => {}, - }); - }, - onClose: () => {}, - }); - } - const depthLabels: Record = { quick: { label: 'Quick', description: `${REVIEW_DEPTH_AGENTS.quick} agent` }, focused: { label: 'Focused', description: `${REVIEW_DEPTH_AGENTS.focused} agents` }, diff --git a/src/renderer/components/AIReviewResultsModal.tsx b/src/renderer/components/AIReviewResultsModal.tsx deleted file mode 100644 index 71857c9df..000000000 --- a/src/renderer/components/AIReviewResultsModal.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React, { useState } from 'react'; -import { DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'; -import { Button } from './ui/button'; -import { Badge } from './ui/badge'; -import { Spinner } from './ui/spinner'; -import { ScrollArea } from './ui/scroll-area'; -import { BaseModalProps } from '@/contexts/ModalProvider'; -import type { AIReviewResult, AIReviewIssue } from '@shared/reviewPreset'; -import { AlertCircle, CheckCircle, Code, FileText, Wrench, Clock, Users } from 'lucide-react'; - -interface AIReviewResultsModalProps { - results: AIReviewResult[]; - isLoading?: boolean; - onRunAnotherReview?: () => void; - onFixIssue?: (issue: AIReviewIssue, result: AIReviewResult) => void; -} - -export type AIReviewResultsModalOverlayProps = BaseModalProps & - AIReviewResultsModalProps & { - initialResults?: AIReviewResult[]; - }; - -export function AIReviewResultsModalOverlay({ - results: initialResults = [], - isLoading = false, - onRunAnotherReview, - onFixIssue, - onClose, -}: AIReviewResultsModalOverlayProps) { - const [activeTab, setActiveTab] = useState<'all' | number>('all'); - const [expandedIssues, setExpandedIssues] = useState>(new Set()); - - const allIssues = initialResults.flatMap((r) => r.issues); - const severityOrder = { critical: 0, major: 1, minor: 2, info: 3 }; - - const sortedIssues = [...allIssues].sort( - (a, b) => severityOrder[a.severity] - severityOrder[b.severity] - ); - - const toggleIssueExpanded = (issueId: string) => { - setExpandedIssues((prev) => { - const next = new Set(prev); - if (next.has(issueId)) { - next.delete(issueId); - } else { - next.add(issueId); - } - return next; - }); - }; - - const handleFix = (issue: AIReviewIssue, result: AIReviewResult) => { - onFixIssue?.(issue, result); - }; - - const severityConfig = { - critical: { - label: 'Critical', - color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', - icon: AlertCircle, - }, - major: { - label: 'Major', - color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', - icon: AlertCircle, - }, - minor: { - label: 'Minor', - color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300', - icon: AlertCircle, - }, - info: { - label: 'Info', - color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', - icon: AlertCircle, - }, - }; - - const formatDuration = (ms: number) => { - if (ms < 1000) return `${ms}ms`; - const seconds = Math.round(ms / 1000); - if (seconds < 60) return `${seconds}s`; - return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; - }; - - const totalIssues = allIssues.length; - const criticalCount = allIssues.filter((i) => i.severity === 'critical').length; - const majorCount = allIssues.filter((i) => i.severity === 'major').length; - - return ( - - -
-
- AI Review Results - {initialResults.length > 0 && ( - - - {initialResults.length} agent{initialResults.length > 1 ? 's' : ''} - - )} -
- {initialResults.length > 0 && ( -
- - - {formatDuration(initialResults[0]?.durationMs || 0)} - - - - {totalIssues} issue{totalIssues !== 1 ? 's' : ''} - -
- )} -
- {(criticalCount > 0 || majorCount > 0) && ( -
- {criticalCount > 0 && ( - - {criticalCount} critical - - )} - {majorCount > 0 && ( - {majorCount} major - )} -
- )} -
- - {isLoading ? ( -
-
- -

Running AI review...

-
-
- ) : allIssues.length === 0 ? ( -
-
- -

No issues found

-

- The review didn't find any significant issues. -

-
-
- ) : ( - <> - {/* Tab bar */} - {initialResults.length > 1 && ( -
- - {initialResults.map((result, index) => ( - - ))} -
- )} - - {/* Issues list */} - -
- {sortedIssues.map((issue) => { - const config = severityConfig[issue.severity]; - const SeverityIcon = config.icon; - const isExpanded = expandedIssues.has(issue.id); - - return ( -
-
-
-
- - - {config.label} - -
-

{issue.title}

- {issue.category && ( -

{issue.category}

- )} -

- {issue.description} -

-
-
- {issue.codeSnapshot && ( - - )} -
- - {/* File path and line range */} - {(issue.filePath || issue.lineRange) && ( -
- {issue.filePath && {issue.filePath}} - {issue.lineRange && ( - - L{issue.lineRange.start}-L{issue.lineRange.end} - - )} -
- )} - - {/* Code snapshot */} - {issue.codeSnapshot && isExpanded && ( -
-
-                            {issue.codeSnapshot}
-                          
-
- )} - - {/* Actions */} - {issue.fixPrompt && ( -
- -
- )} -
-
- ); - })} -
-
- - )} - - - - - -
- ); -} diff --git a/src/renderer/contexts/ModalProvider.tsx b/src/renderer/contexts/ModalProvider.tsx index 1ff0f56ff..cb39ffd93 100644 --- a/src/renderer/contexts/ModalProvider.tsx +++ b/src/renderer/contexts/ModalProvider.tsx @@ -8,7 +8,6 @@ import { GithubDeviceFlowModalOverlay } from '@/components/GithubDeviceFlowModal import { McpServerModal } from '@/components/mcp/McpServerModal'; import { ChangelogModalOverlay } from '@/components/ChangelogModal'; import { AIReviewConfigModalOverlay } from '@/components/AIReviewConfigModal'; -import { AIReviewResultsModalOverlay } from '@/components/AIReviewResultsModal'; // Define overlays here so we can use them in the showOverlay function const modalRegistry = { @@ -21,7 +20,6 @@ const modalRegistry = { githubDeviceFlowModal: GithubDeviceFlowModalOverlay, mcpServerModal: McpServerModal, aiReviewConfigModal: AIReviewConfigModalOverlay, - aiReviewResultsModal: AIReviewResultsModalOverlay, // eslint-disable-next-line @typescript-eslint/no-explicit-any } satisfies Record>; diff --git a/src/shared/reviewPreset.ts b/src/shared/reviewPreset.ts index 922160913..ffee89773 100644 --- a/src/shared/reviewPreset.ts +++ b/src/shared/reviewPreset.ts @@ -60,13 +60,13 @@ export interface AIReviewResult { // Review prompt templates export const REVIEW_PROMPTS = { fileChanges: { - quick: `You are a code reviewer. Review the following file changes for: + 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 in a structured format with specific issues found.`, - focused: `You are a thorough code reviewer. Review the following file changes for: + 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 @@ -75,7 +75,7 @@ Provide your review in a structured format with specific issues found.`, - Code maintainability Provide your review in a structured format with specific issues found.`, - comprehensive: `You are an expert code reviewer conducting a comprehensive review. Review the following file changes for: + 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 From 748b486637669335a392b482edb9a20c5ca6ef5a Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Sun, 5 Apr 2026 21:56:08 +0800 Subject: [PATCH 5/8] fix(review): address ai review feedback for preview services - Change TCP probe to HTTP HEAD request for dev servers - Replace raw kill(0) checks with exitCode/signalCode cross-platform checks - Handle potential duplicate CLI flag appending in hostPreview - Add missing error emission for failed auto-installs - Explicitly throw errors when provider status check fails in reviewChat - Add null check for conversation creation in review chat --- src/main/services/hostPreviewService.ts | 92 ++++++++++++++----------- src/renderer/lib/quickPreview.ts | 1 + src/renderer/lib/reviewChat.ts | 8 ++- 3 files changed, 60 insertions(+), 41 deletions(-) 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/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 }, From 79861e35aab4eb748a3eb2034a1738d31f8ea047 Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Mon, 6 Apr 2026 08:22:02 +0800 Subject: [PATCH 6/8] fix(ai-review): address CodeRabbit review comments - Add JSON schema to REVIEW_PROMPTS for structured output - Parallelize agent launches with Promise.all in launchReviewAgents - Fix normalizeSeverity to properly return 'info' instead of mapping to 'minor' - Add explicit inCodeBlock boolean flag for fenced code block parsing - Add ReviewMessage type to shared types (fixes cross-boundary import) - Update buildReviewPrompt to accept reviewType parameter Co-Authored-By: Claude Opus 4.6 --- src/renderer/lib/aiReview.ts | 73 +++++++++++++++++++++--------------- src/shared/reviewPreset.ts | 26 +++++++++++-- 2 files changed, 64 insertions(+), 35 deletions(-) diff --git a/src/renderer/lib/aiReview.ts b/src/renderer/lib/aiReview.ts index 4bb02a076..6b96883e1 100644 --- a/src/renderer/lib/aiReview.ts +++ b/src/renderer/lib/aiReview.ts @@ -4,13 +4,14 @@ import type { AIReviewIssue, AIReviewResult, ReviewDepth, + ReviewType, + 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 type { Message } from '../../main/services/DatabaseService'; import { terminalSessionRegistry } from '../terminal/SessionRegistry'; const CONVERSATIONS_CHANGED_EVENT = 'emdash:conversations-changed'; @@ -38,9 +39,13 @@ export async function captureTerminalSnapshot(ptyId: string): Promise { return response.snapshot.data; } -function buildReviewPrompt(depth: ReviewDepth, content?: string): string { - const key = 'fileChanges'; - const template = REVIEW_PROMPTS[key][depth]; +function buildReviewPrompt(reviewType: ReviewType, depth: ReviewDepth, content?: string): string { + // Map review type to prompt key (file-changes -> fileChanges) + const key = reviewType === 'file-changes' ? 'fileChanges' : 'fileChanges'; + const template = REVIEW_PROMPTS[key]?.[depth]; + if (!template) { + throw new Error(`No review prompt found for ${reviewType}/${depth}`); + } if (content) { return `${template}\n\n--- Content to review ---\n${content}`; } @@ -115,21 +120,22 @@ export async function launchReviewAgents( ): Promise<{ reviewId: string; conversationIds: string[]; ptyIds: string[] }> { const reviewId = generateId(); const agentCount = REVIEW_DEPTH_AGENTS[config.depth]; - const conversationIds: string[] = []; - const ptyIds: string[] = []; - for (let i = 0; i < agentCount; i++) { - const prompt = buildReviewPrompt(config.depth, content); - const { conversationId, ptyId } = await launchReviewAgent({ + // Launch all agents in parallel + const prompt = buildReviewPrompt(config.reviewType, config.depth, content); + const launches = Array.from({ length: agentCount }, () => + launchReviewAgent({ taskId, taskPath, reviewId, agent: config.providerId, prompt, - }); - conversationIds.push(conversationId); - ptyIds.push(ptyId); - } + }) + ); + + const results = await Promise.all(launches); + const conversationIds = results.map((r) => r.conversationId); + const ptyIds = results.map((r) => r.ptyId); return { reviewId, conversationIds, ptyIds }; } @@ -137,7 +143,7 @@ export async function launchReviewAgents( export async function pollReviewMessages( conversationId: string, sinceTimestamp?: string -): Promise<{ messages: Message[]; hasNewMessages: boolean }> { +): 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); @@ -147,7 +153,7 @@ export async function pollReviewMessages( }; } -export function parseReviewMessages(messages: Message[]): AIReviewIssue[] { +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(); @@ -198,7 +204,8 @@ function normalizeSeverity(severity: unknown): 'critical' | 'major' | 'minor' | const s = String(severity || '').toLowerCase(); if (s === 'critical' || s === 'error' || s === 'blocker') return 'critical'; if (s === 'major' || s === 'warning') return 'major'; - if (s === 'minor' || s === 'info') return 'minor'; + if (s === 'minor') return 'minor'; + if (s === 'info') return 'info'; return 'info'; } @@ -207,8 +214,25 @@ function parseMarkdownIssues(content: string): 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) { @@ -246,25 +270,12 @@ function parseMarkdownIssues(content: string): AIReviewIssue[] { continue; } - // Accumulate code blocks - if (line.startsWith('```')) { - if (currentCodeBlock.length > 0) { - // End of code block - if (currentIssue) { - currentIssue.codeSnapshot = currentCodeBlock.join('\n'); - } - currentCodeBlock = []; - } - // Start of code block - skip the fence - 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 (currentCodeBlock.length > 0 || line.match(/^\s{2,}/)) { + } else if (inCodeBlock || line.match(/^\s{2,}/)) { currentCodeBlock.push(line); } else if (line.trim()) { currentIssue.description = (currentIssue.description || '') + line + '\n'; @@ -281,7 +292,7 @@ function parseMarkdownIssues(content: string): AIReviewIssue[] { } export async function aggregateReviewResults( - results: Array<{ conversationId: string; messages: Message[] }>, + results: Array<{ conversationId: string; messages: ReviewMessage[] }>, config: AIReviewConfig, reviewId: string, durationMs: number diff --git a/src/shared/reviewPreset.ts b/src/shared/reviewPreset.ts index ffee89773..ba104b608 100644 --- a/src/shared/reviewPreset.ts +++ b/src/shared/reviewPreset.ts @@ -26,7 +26,16 @@ export const REVIEW_DEPTH_AGENTS: Record = { comprehensive: 5, }; -export type ReviewType = 'file-changes'; +export type ReviewType = 'file-changes' | 'agent-output'; + +// 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; @@ -58,6 +67,9 @@ export interface AIReviewResult { } // 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: @@ -65,7 +77,9 @@ export const REVIEW_PROMPTS = { - Obvious correctness problems - Major performance concerns -Provide your review in a structured format with specific issues found.`, +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 @@ -74,7 +88,9 @@ Provide your review in a structured format with specific issues found.`, - Testing gaps - Code maintainability -Provide your review in a structured format with specific issues found.`, +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) @@ -85,7 +101,9 @@ Provide your review in a structured format with specific issues found.`, - Best practices adherence - Potential bugs and race conditions -Provide your review in a structured format with specific issues found, including severity and category.`, +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.`, }, }; From e56180125a0d30b156f0501f7c2250a93762aeab Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Mon, 6 Apr 2026 08:39:52 +0800 Subject: [PATCH 7/8] fix(ai-review): handle partial agent launch failures gracefully - Use Promise.allSettled instead of Promise.all to handle partial failures - When some agents fail to launch, log warnings and proceed with successful ones - Remove incomplete 'agent-output' review type (only 'file-changes' is supported) - Simplify buildReviewPrompt now that only file-changes type exists Co-Authored-By: Claude Opus 4.6 --- src/renderer/lib/aiReview.ts | 42 ++++++++++++++++++++++++++---------- src/shared/reviewPreset.ts | 2 +- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/renderer/lib/aiReview.ts b/src/renderer/lib/aiReview.ts index 6b96883e1..3d1839aa2 100644 --- a/src/renderer/lib/aiReview.ts +++ b/src/renderer/lib/aiReview.ts @@ -4,7 +4,6 @@ import type { AIReviewIssue, AIReviewResult, ReviewDepth, - ReviewType, ReviewMessage, } from '@shared/reviewPreset'; import { REVIEW_DEPTH_AGENTS, REVIEW_PROMPTS } from '@shared/reviewPreset'; @@ -39,12 +38,10 @@ export async function captureTerminalSnapshot(ptyId: string): Promise { return response.snapshot.data; } -function buildReviewPrompt(reviewType: ReviewType, depth: ReviewDepth, content?: string): string { - // Map review type to prompt key (file-changes -> fileChanges) - const key = reviewType === 'file-changes' ? 'fileChanges' : 'fileChanges'; - const template = REVIEW_PROMPTS[key]?.[depth]; +function buildReviewPrompt(depth: ReviewDepth, content?: string): string { + const template = REVIEW_PROMPTS.fileChanges[depth]; if (!template) { - throw new Error(`No review prompt found for ${reviewType}/${depth}`); + throw new Error(`No review prompt found for file-changes/${depth}`); } if (content) { return `${template}\n\n--- Content to review ---\n${content}`; @@ -121,8 +118,8 @@ export async function launchReviewAgents( const reviewId = generateId(); const agentCount = REVIEW_DEPTH_AGENTS[config.depth]; - // Launch all agents in parallel - const prompt = buildReviewPrompt(config.reviewType, config.depth, content); + // Launch all agents in parallel using Promise.allSettled to handle partial failures + const prompt = buildReviewPrompt(config.depth, content); const launches = Array.from({ length: agentCount }, () => launchReviewAgent({ taskId, @@ -133,9 +130,32 @@ export async function launchReviewAgents( }) ); - const results = await Promise.all(launches); - const conversationIds = results.map((r) => r.conversationId); - const ptyIds = results.map((r) => r.ptyId); + 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 }; } diff --git a/src/shared/reviewPreset.ts b/src/shared/reviewPreset.ts index ba104b608..e442fa224 100644 --- a/src/shared/reviewPreset.ts +++ b/src/shared/reviewPreset.ts @@ -26,7 +26,7 @@ export const REVIEW_DEPTH_AGENTS: Record = { comprehensive: 5, }; -export type ReviewType = 'file-changes' | 'agent-output'; +export type ReviewType = 'file-changes'; // Message type for review conversations (shared between renderer and main) export interface ReviewMessage { From d0c3e0620e413f66893f70afa8bd60d39d9b6387 Mon Sep 17 00:00:00 2001 From: Zhichang Yu Date: Wed, 8 Apr 2026 14:31:02 +0800 Subject: [PATCH 8/8] fix: unify Claude project path encoding and fix buildReviewPrompt reviewType handling - Extract normalizeClaudeProjectPath() in ptyManager.ts to replace inconsistent inline encoders across claudeSessionFileExists, discoverExistingClaudeSession, and ptyIpc.ts - Fix buildReviewPrompt() to accept reviewType parameter and use it for template selection with safe fallback to fileChanges Co-Authored-By: Claude Opus 4.6 --- src/main/services/ptyIpc.ts | 4 ++-- src/main/services/ptyManager.ts | 15 ++++++++++++--- src/renderer/lib/aiReview.ts | 17 +++++++++++++---- 3 files changed, 27 insertions(+), 9 deletions(-) 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 0a8d097e7..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(/[^a-zA-Z0-9]/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; diff --git a/src/renderer/lib/aiReview.ts b/src/renderer/lib/aiReview.ts index 3d1839aa2..7d17c29a0 100644 --- a/src/renderer/lib/aiReview.ts +++ b/src/renderer/lib/aiReview.ts @@ -38,10 +38,19 @@ export async function captureTerminalSnapshot(ptyId: string): Promise { return response.snapshot.data; } -function buildReviewPrompt(depth: ReviewDepth, content?: string): string { - const template = REVIEW_PROMPTS.fileChanges[depth]; +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) { - throw new Error(`No review prompt found for file-changes/${depth}`); + // 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}`; @@ -119,7 +128,7 @@ export async function launchReviewAgents( const agentCount = REVIEW_DEPTH_AGENTS[config.depth]; // Launch all agents in parallel using Promise.allSettled to handle partial failures - const prompt = buildReviewPrompt(config.depth, content); + const prompt = buildReviewPrompt(config.reviewType, config.depth, content); const launches = Array.from({ length: agentCount }, () => launchReviewAgent({ taskId,