// @ts-nocheck import { useState, useEffect, useCallback, useRef } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { getElectronAPI, GitHubIssue, GitHubComment, IssueValidationResult, IssueValidationEvent, StoredValidation, } from '@/lib/electron'; import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { toast } from 'sonner'; import { isValidationStale } from '../utils'; import { useValidateIssue, useMarkValidationViewed } from '@/hooks/mutations'; const logger = createLogger('IssueValidation'); /** * Extract model string from PhaseModelEntry or string (handles both formats) */ function extractModel(entry: PhaseModelEntry | string | undefined): ModelId | undefined { if (!entry) return undefined; if (typeof entry === 'string') { return entry as ModelId; } return entry.model; } interface UseIssueValidationOptions { selectedIssue: GitHubIssue | null; showValidationDialog: boolean; onValidationResultChange: (result: IssueValidationResult | null) => void; onShowValidationDialogChange: (show: boolean) => void; } export function useIssueValidation({ selectedIssue, showValidationDialog, onValidationResultChange, onShowValidationDialogChange, }: UseIssueValidationOptions) { const { currentProject, phaseModels, muteDoneSound } = useAppStore(); const [validatingIssues, setValidatingIssues] = useState>(new Set()); const [cachedValidations, setCachedValidations] = useState>( new Map() ); const audioRef = useRef(null); // React Query mutations const validateIssueMutation = useValidateIssue(currentProject?.path ?? ''); const markViewedMutation = useMarkValidationViewed(currentProject?.path ?? ''); // Refs for stable event handler (avoids re-subscribing on state changes) const selectedIssueRef = useRef(null); const showValidationDialogRef = useRef(false); // Keep refs in sync with state for stable event handler useEffect(() => { selectedIssueRef.current = selectedIssue; }, [selectedIssue]); useEffect(() => { showValidationDialogRef.current = showValidationDialog; }, [showValidationDialog]); // Load cached validations on mount useEffect(() => { let isMounted = true; const loadCachedValidations = async () => { if (!currentProject?.path) return; try { const api = getElectronAPI(); if (api.github?.getValidations) { const result = await api.github.getValidations(currentProject.path); if (isMounted && result.success && result.validations) { const map = new Map(); for (const v of result.validations) { map.set(v.issueNumber, v); } setCachedValidations(map); } } } catch (err) { if (isMounted) { logger.error('Failed to load cached validations:', err); } } }; loadCachedValidations(); return () => { isMounted = false; }; }, [currentProject?.path]); // Load running validations on mount (restore validatingIssues state) useEffect(() => { let isMounted = true; const loadRunningValidations = async () => { if (!currentProject?.path) return; try { const api = getElectronAPI(); if (api.github?.getValidationStatus) { const result = await api.github.getValidationStatus(currentProject.path); if (isMounted && result.success && result.runningIssues) { setValidatingIssues(new Set(result.runningIssues)); } } } catch (err) { if (isMounted) { logger.error('Failed to load running validations:', err); } } }; loadRunningValidations(); return () => { isMounted = false; }; }, [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 ( selectedIssueRef.current?.number === event.issueNumber && showValidationDialogRef.current ) { onValidationResultChange(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 ( selectedIssueRef.current?.number === event.issueNumber && showValidationDialogRef.current ) { onShowValidationDialogChange(false); } break; } }; const unsubscribe = api.github.onValidationEvent(handleValidationEvent); return () => unsubscribe(); }, [currentProject?.path, muteDoneSound, onValidationResultChange, onShowValidationDialogChange]); // Cleanup audio element on unmount to prevent memory leaks useEffect(() => { return () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } }; }, []); const handleValidateIssue = useCallback( async ( issue: GitHubIssue, options: { forceRevalidate?: boolean; model?: ModelId | PhaseModelEntry; // Accept either model ID (backward compat) or PhaseModelEntry modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking/reasoning comments?: GitHubComment[]; linkedPRs?: LinkedPRInfo[]; } = {} ) => { const { forceRevalidate = false, model, modelEntry, comments, linkedPRs } = options; if (!currentProject?.path) { toast.error('No project selected'); return; } // Check if already validating this issue if (validatingIssues.has(issue.number) || validateIssueMutation.isPending) { toast.info(`Validation already in progress for issue #${issue.number}`); return; } // Check for cached result - if fresh, show it directly (unless force revalidate) const cached = cachedValidations.get(issue.number); if (cached && !forceRevalidate && !isValidationStale(cached.validatedAt)) { // Show cached result directly onValidationResultChange(cached.result); onShowValidationDialogChange(true); return; } // Use provided model override or fall back to phaseModels.validationModel // Extract model string and thinking level from PhaseModelEntry (handles both old string format and new object format) const effectiveModelEntry = modelEntry ? modelEntry : model ? typeof model === 'string' ? { model: model as ModelId } : model : phaseModels.validationModel; const normalizedEntry = typeof effectiveModelEntry === 'string' ? { model: effectiveModelEntry as ModelId } : effectiveModelEntry; const modelToUse = normalizedEntry.model; const thinkingLevelToUse = normalizedEntry.thinkingLevel; const reasoningEffortToUse = normalizedEntry.reasoningEffort; // Use mutation to trigger validation (toast is handled by mutation) validateIssueMutation.mutate({ issue, model: modelToUse, thinkingLevel: thinkingLevelToUse, reasoningEffort: reasoningEffortToUse, comments, linkedPRs, }); }, [ currentProject?.path, validatingIssues, cachedValidations, phaseModels.validationModel, validateIssueMutation, onValidationResultChange, onShowValidationDialogChange, ] ); // View cached validation result const handleViewCachedValidation = useCallback( async (issue: GitHubIssue) => { const cached = cachedValidations.get(issue.number); if (cached) { onValidationResultChange(cached.result); onShowValidationDialogChange(true); // Mark as viewed if not already viewed if (!cached.viewedAt && currentProject?.path) { markViewedMutation.mutate(issue.number, { onSuccess: () => { // 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; }); }, }); } } }, [ cachedValidations, currentProject?.path, markViewedMutation, onValidationResultChange, onShowValidationDialogChange, ] ); return { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation, isValidating: validateIssueMutation.isPending, }; }