import { useState, useEffect, useCallback, useRef } from 'react'; import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X, Wand2, GitPullRequest, User, CheckCircle, Clock, Sparkles, } from 'lucide-react'; import { getElectronAPI, GitHubIssue, IssueValidationResult, IssueComplexity, IssueValidationEvent, StoredValidation, } from '@/lib/electron'; /** * Map issue complexity to feature priority. * Lower complexity issues get higher priority (1 = high, 2 = medium). */ function getFeaturePriority(complexity: IssueComplexity | undefined): number { switch (complexity) { case 'trivial': case 'simple': return 1; // High priority for easy wins case 'moderate': case 'complex': case 'very_complex': default: return 2; // Medium priority for larger efforts } } import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { Markdown } from '@/components/ui/markdown'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { ValidationDialog } from './github-issues-view/validation-dialog'; export function GitHubIssuesView() { const [openIssues, setOpenIssues] = useState([]); const [closedIssues, setClosedIssues] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [selectedIssue, setSelectedIssue] = useState(null); const [validatingIssues, setValidatingIssues] = useState>(new Set()); const [validationResult, setValidationResult] = useState(null); const [showValidationDialog, setShowValidationDialog] = useState(false); // Track cached validations for display const [cachedValidations, setCachedValidations] = useState>( new Map() ); const audioRef = useRef(null); const { currentProject, validationModel, muteDoneSound } = useAppStore(); const fetchIssues = useCallback(async () => { if (!currentProject?.path) { setError('No project selected'); setLoading(false); return; } try { setError(null); const api = getElectronAPI(); if (api.github) { const result = await api.github.listIssues(currentProject.path); if (result.success) { setOpenIssues(result.openIssues || []); setClosedIssues(result.closedIssues || []); } else { setError(result.error || 'Failed to fetch issues'); } } } catch (err) { console.error('[GitHubIssuesView] Error fetching issues:', err); setError(err instanceof Error ? err.message : 'Failed to fetch issues'); } finally { setLoading(false); setRefreshing(false); } }, [currentProject?.path]); useEffect(() => { fetchIssues(); }, [fetchIssues]); // Load cached validations on mount useEffect(() => { const loadCachedValidations = async () => { if (!currentProject?.path) return; try { const api = getElectronAPI(); if (api.github?.getValidations) { const result = await api.github.getValidations(currentProject.path); if (result.success && result.validations) { const map = new Map(); for (const v of result.validations) { map.set(v.issueNumber, v); } setCachedValidations(map); } } } catch (err) { console.error('[GitHubIssuesView] Failed to load cached validations:', err); } }; loadCachedValidations(); }, [currentProject?.path]); // Load running validations on mount (restore validatingIssues state) useEffect(() => { const loadRunningValidations = async () => { if (!currentProject?.path) return; try { const api = getElectronAPI(); if (api.github?.getValidationStatus) { const result = await api.github.getValidationStatus(currentProject.path); if (result.success && result.runningIssues) { setValidatingIssues(new Set(result.runningIssues)); } } } catch (err) { console.error('[GitHubIssuesView] Failed to load running validations:', err); } }; loadRunningValidations(); }, [currentProject?.path]); // Subscribe to validation events useEffect(() => { const api = getElectronAPI(); if (!api.github?.onValidationEvent) return; const handleValidationEvent = (event: IssueValidationEvent) => { // Only handle events for current project if (event.projectPath !== currentProject?.path) return; switch (event.type) { case 'issue_validation_start': setValidatingIssues((prev) => new Set([...prev, event.issueNumber])); break; case 'issue_validation_complete': setValidatingIssues((prev) => { const next = new Set(prev); next.delete(event.issueNumber); return next; }); // Update cached validations (use event.model to avoid stale closure race condition) setCachedValidations((prev) => { const next = new Map(prev); next.set(event.issueNumber, { issueNumber: event.issueNumber, issueTitle: event.issueTitle, validatedAt: new Date().toISOString(), model: event.model, result: event.result, }); return next; }); // Show toast notification toast.success(`Issue #${event.issueNumber} validated: ${event.result.verdict}`, { description: event.result.verdict === 'valid' ? 'Issue is ready to be converted to a task' : event.result.verdict === 'invalid' ? 'Issue may have problems' : 'Issue needs clarification', }); // Play audio notification (if not muted) if (!muteDoneSound) { try { if (!audioRef.current) { audioRef.current = new Audio('/sounds/ding.mp3'); } audioRef.current.play().catch(() => { // Audio play might fail due to browser restrictions }); } catch { // Ignore audio errors } } // If validation dialog is open for this issue, update the result if (selectedIssue?.number === event.issueNumber && showValidationDialog) { setValidationResult(event.result); } break; case 'issue_validation_error': setValidatingIssues((prev) => { const next = new Set(prev); next.delete(event.issueNumber); return next; }); toast.error(`Validation failed for issue #${event.issueNumber}`, { description: event.error, }); if (selectedIssue?.number === event.issueNumber && showValidationDialog) { setShowValidationDialog(false); } break; } }; const unsubscribe = api.github.onValidationEvent(handleValidationEvent); return () => unsubscribe(); }, [currentProject?.path, selectedIssue, showValidationDialog, validationModel, muteDoneSound]); // Cleanup audio element on unmount to prevent memory leaks useEffect(() => { return () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } }; }, []); const handleRefresh = useCallback(() => { setRefreshing(true); fetchIssues(); }, [fetchIssues]); const handleOpenInGitHub = useCallback((url: string) => { const api = getElectronAPI(); api.openExternalLink(url); }, []); const handleValidateIssue = useCallback( async (issue: GitHubIssue, showDialog = true) => { if (!currentProject?.path) { toast.error('No project selected'); return; } // Check if already validating this issue if (validatingIssues.has(issue.number)) { toast.info(`Validation already in progress for issue #${issue.number}`); return; } // Check for cached result - if fresh, show it directly const cached = cachedValidations.get(issue.number); if (cached && showDialog) { // Check if validation is stale (older than 24 hours) const validatedAt = new Date(cached.validatedAt); const hoursSinceValidation = (Date.now() - validatedAt.getTime()) / (1000 * 60 * 60); const isStale = hoursSinceValidation > 24; if (!isStale) { // Show cached result directly setValidationResult(cached.result); setShowValidationDialog(true); return; } } // Start async validation setValidationResult(null); if (showDialog) { setShowValidationDialog(true); } try { const api = getElectronAPI(); if (api.github?.validateIssue) { const result = await api.github.validateIssue( currentProject.path, { issueNumber: issue.number, issueTitle: issue.title, issueBody: issue.body || '', issueLabels: issue.labels.map((l) => l.name), }, validationModel ); if (!result.success) { toast.error(result.error || 'Failed to start validation'); if (showDialog) { setShowValidationDialog(false); } } // On success, the result will come through the event stream } } catch (err) { console.error('[GitHubIssuesView] Validation error:', err); toast.error(err instanceof Error ? err.message : 'Failed to validate issue'); if (showDialog) { setShowValidationDialog(false); } } }, [currentProject?.path, validatingIssues, cachedValidations, validationModel] ); // View cached validation result const handleViewCachedValidation = useCallback( async (issue: GitHubIssue) => { const cached = cachedValidations.get(issue.number); if (cached) { setValidationResult(cached.result); setShowValidationDialog(true); // Mark as viewed if not already viewed if (!cached.viewedAt && currentProject?.path) { try { const api = getElectronAPI(); if (api.github?.markValidationViewed) { await api.github.markValidationViewed(currentProject.path, issue.number); // Update local state setCachedValidations((prev) => { const next = new Map(prev); const updated = prev.get(issue.number); if (updated) { next.set(issue.number, { ...updated, viewedAt: new Date().toISOString(), }); } return next; }); } } catch (err) { console.error('[GitHubIssuesView] Failed to mark validation as viewed:', err); } } } }, [cachedValidations, currentProject?.path] ); const handleConvertToTask = useCallback( async (issue: GitHubIssue, validation: IssueValidationResult) => { if (!currentProject?.path) { toast.error('No project selected'); return; } try { const api = getElectronAPI(); if (api.features?.create) { // Build description from issue body + validation info const description = [ `**From GitHub Issue #${issue.number}**`, '', issue.body || 'No description provided.', '', '---', '', '**AI Validation Analysis:**', validation.reasoning, validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '', validation.relatedFiles?.length ? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}` : '', ] .filter(Boolean) .join('\n'); const feature = { id: `issue-${issue.number}-${crypto.randomUUID()}`, title: issue.title, description, category: 'From GitHub', status: 'backlog' as const, passes: false, priority: getFeaturePriority(validation.estimatedComplexity), model: 'opus' as const, thinkingLevel: 'none' as const, branchName: '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; const result = await api.features.create(currentProject.path, feature); if (result.success) { toast.success(`Created task: ${issue.title}`); } else { toast.error(result.error || 'Failed to create task'); } } } catch (err) { console.error('[GitHubIssuesView] Convert to task error:', err); toast.error(err instanceof Error ? err.message : 'Failed to create task'); } }, [currentProject?.path] ); const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); }; if (loading) { return (
); } if (error) { return (

Failed to Load Issues

{error}

); } const totalIssues = openIssues.length + closedIssues.length; return (
{/* Issues List */}
{/* Header */}

Issues

{totalIssues === 0 ? 'No issues found' : `${openIssues.length} open, ${closedIssues.length} closed`}

{/* Issues List */}
{totalIssues === 0 ? (

No Issues

This repository has no issues yet.

) : (
{/* Open Issues */} {openIssues.map((issue) => ( setSelectedIssue(issue)} onOpenExternal={() => handleOpenInGitHub(issue.url)} formatDate={formatDate} cachedValidation={cachedValidations.get(issue.number)} /> ))} {/* Closed Issues Section */} {closedIssues.length > 0 && ( <>
Closed Issues ({closedIssues.length})
{closedIssues.map((issue) => ( setSelectedIssue(issue)} onOpenExternal={() => handleOpenInGitHub(issue.url)} formatDate={formatDate} cachedValidation={cachedValidations.get(issue.number)} /> ))} )}
)}
{/* Issue Detail Panel */} {selectedIssue && (
{/* Detail Header */}
{selectedIssue.state === 'OPEN' ? ( ) : ( )} #{selectedIssue.number} {selectedIssue.title}
{(() => { const isValidating = validatingIssues.has(selectedIssue.number); const cached = cachedValidations.get(selectedIssue.number); const isStale = cached && (Date.now() - new Date(cached.validatedAt).getTime()) / (1000 * 60 * 60) > 24; if (isValidating) { return ( ); } if (cached && !isStale) { return ( <> ); } if (cached && isStale) { return ( <> ); } return ( ); })()}
{/* Issue Detail Content */}
{/* Title */}

{selectedIssue.title}

{/* Meta info */}
{selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'} #{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '} {selectedIssue.author.login}
{/* Labels */} {selectedIssue.labels.length > 0 && (
{selectedIssue.labels.map((label) => ( {label.name} ))}
)} {/* Assignees */} {selectedIssue.assignees && selectedIssue.assignees.length > 0 && (
Assigned to:
{selectedIssue.assignees.map((assignee) => ( {assignee.avatarUrl && ( {assignee.login} )} {assignee.login} ))}
)} {/* Linked Pull Requests */} {selectedIssue.linkedPRs && selectedIssue.linkedPRs.length > 0 && (
Linked Pull Requests
{selectedIssue.linkedPRs.map((pr) => (
{pr.state === 'open' ? 'Open' : pr.state === 'merged' ? 'Merged' : 'Closed'} #{pr.number} {pr.title}
))}
)} {/* Body */} {selectedIssue.body ? ( {selectedIssue.body} ) : (

No description provided.

)} {/* Open in GitHub CTA */}

View comments, add reactions, and more on GitHub.

)} {/* Validation Dialog */}
); } interface IssueRowProps { issue: GitHubIssue; isSelected: boolean; onClick: () => void; onOpenExternal: () => void; formatDate: (date: string) => string; /** Cached validation for this issue (if any) */ cachedValidation?: StoredValidation | null; } function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate, cachedValidation, }: IssueRowProps) { // Check if validation is unviewed (exists, not stale, not viewed) const hasUnviewedValidation = cachedValidation && !cachedValidation.viewedAt && (() => { const hoursSince = (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60); return hoursSince <= 24; })(); return (
{issue.state === 'OPEN' ? ( ) : ( )}
{issue.title}
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
{/* Labels */} {issue.labels.map((label) => ( {label.name} ))} {/* Linked PR indicator */} {issue.linkedPRs && issue.linkedPRs.length > 0 && ( {issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''} )} {/* Assignee indicator */} {issue.assignees && issue.assignees.length > 0 && ( {issue.assignees.map((a) => a.login).join(', ')} )} {/* Unviewed validation indicator */} {hasUnviewedValidation && ( Analysis Ready )}
); }