From dd86e987a41b74190255af944272eccfeb06634b Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 24 Dec 2025 02:23:12 +0100 Subject: [PATCH] feat: Introduce ErrorState and LoadingState components for improved UI feedback - Added ErrorState component to display error messages with retry functionality, enhancing user experience during issue loading failures. - Implemented LoadingState component to provide visual feedback while issues are being fetched, improving the overall responsiveness of the GitHubIssuesView. - Refactored GitHubIssuesView to utilize the new components, streamlining error and loading handling logic. --- apps/ui/src/components/ui/error-state.tsx | 36 + apps/ui/src/components/ui/loading-state.tsx | 17 + .../components/views/github-issues-view.tsx | 835 +----------------- .../github-issues-view/components/index.ts | 3 + .../components/issue-detail-panel.tsx | 242 +++++ .../components/issue-row.tsx | 136 +++ .../components/issues-list-header.tsx | 38 + .../views/github-issues-view/constants.ts | 1 + .../views/github-issues-view/dialogs/index.ts | 1 + .../{ => dialogs}/validation-dialog.tsx | 2 +- .../views/github-issues-view/hooks/index.ts | 2 + .../hooks/use-github-issues.ts | 58 ++ .../hooks/use-issue-validation.ts | 330 +++++++ .../views/github-issues-view/types.ts | 28 + .../views/github-issues-view/utils.ts | 33 + 15 files changed, 967 insertions(+), 795 deletions(-) create mode 100644 apps/ui/src/components/ui/error-state.tsx create mode 100644 apps/ui/src/components/ui/loading-state.tsx create mode 100644 apps/ui/src/components/views/github-issues-view/components/index.ts create mode 100644 apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx create mode 100644 apps/ui/src/components/views/github-issues-view/components/issue-row.tsx create mode 100644 apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx create mode 100644 apps/ui/src/components/views/github-issues-view/constants.ts create mode 100644 apps/ui/src/components/views/github-issues-view/dialogs/index.ts rename apps/ui/src/components/views/github-issues-view/{ => dialogs}/validation-dialog.tsx (99%) create mode 100644 apps/ui/src/components/views/github-issues-view/hooks/index.ts create mode 100644 apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts create mode 100644 apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts create mode 100644 apps/ui/src/components/views/github-issues-view/types.ts create mode 100644 apps/ui/src/components/views/github-issues-view/utils.ts diff --git a/apps/ui/src/components/ui/error-state.tsx b/apps/ui/src/components/ui/error-state.tsx new file mode 100644 index 00000000..3ac336af --- /dev/null +++ b/apps/ui/src/components/ui/error-state.tsx @@ -0,0 +1,36 @@ +import { CircleDot, RefreshCw } from 'lucide-react'; +import { Button } from './button'; + +interface ErrorStateProps { + /** Error message to display */ + error: string; + /** Title for the error state (default: "Failed to Load") */ + title?: string; + /** Callback when retry button is clicked */ + onRetry?: () => void; + /** Text for the retry button (default: "Try Again") */ + retryText?: string; +} + +export function ErrorState({ + error, + title = 'Failed to Load', + onRetry, + retryText = 'Try Again', +}: ErrorStateProps) { + return ( +
+
+ +
+

{title}

+

{error}

+ {onRetry && ( + + )} +
+ ); +} diff --git a/apps/ui/src/components/ui/loading-state.tsx b/apps/ui/src/components/ui/loading-state.tsx new file mode 100644 index 00000000..9ae6ff3b --- /dev/null +++ b/apps/ui/src/components/ui/loading-state.tsx @@ -0,0 +1,17 @@ +import { Loader2 } from 'lucide-react'; + +interface LoadingStateProps { + /** Optional custom message to display below the spinner */ + message?: string; + /** Optional custom size class for the spinner (default: h-8 w-8) */ + size?: string; +} + +export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) { + return ( +
+ + {message &&

{message}

} +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 3692d798..6081a2f6 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -1,81 +1,35 @@ -import { useState, useEffect, useCallback, useRef, useMemo } 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 { useState, useCallback, useMemo } from 'react'; +import { CircleDot, RefreshCw } from 'lucide-react'; +import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { Button } from '@/components/ui/button'; -import { Markdown } from '@/components/ui/markdown'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { LoadingState } from '@/components/ui/loading-state'; +import { ErrorState } from '@/components/ui/error-state'; import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; -import { ValidationDialog } from './github-issues-view/validation-dialog'; +import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'; +import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components'; +import { ValidationDialog } from './github-issues-view/dialogs'; +import { formatDate, getFeaturePriority } from './github-issues-view/utils'; 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() - ); - // Track revalidation confirmation dialog const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false); - const audioRef = useRef(null); - // Refs for stable event handler (avoids re-subscribing on state changes) - const selectedIssueRef = useRef(null); - const showValidationDialogRef = useRef(false); - const { - currentProject, - validationModel, - muteDoneSound, - defaultAIProfileId, - aiProfiles, - getCurrentWorktree, - worktreesByProject, - } = useAppStore(); + + const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } = + useAppStore(); + + const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues(); + + const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } = + useIssueValidation({ + selectedIssue, + showValidationDialog, + onValidationResultChange: setValidationResult, + onShowValidationDialogChange: setShowValidationDialog, + }); // Get default AI profile for task creation const defaultProfile = useMemo(() => { @@ -98,328 +52,11 @@ export function GitHubIssuesView() { return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || ''; }, [currentProject?.path, getCurrentWorktree, worktreesByProject]); - 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(() => { - 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) { - console.error('[GitHubIssuesView] 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) { - console.error('[GitHubIssuesView] Failed to load running validations:', err); - } - } - }; - - loadRunningValidations(); - - return () => { - isMounted = false; - }; - }, [currentProject?.path]); - - // Keep refs in sync with state for stable event handler - useEffect(() => { - selectedIssueRef.current = selectedIssue; - }, [selectedIssue]); - - useEffect(() => { - showValidationDialogRef.current = showValidationDialog; - }, [showValidationDialog]); - - // 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 - ) { - 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 ( - selectedIssueRef.current?.number === event.issueNumber && - showValidationDialogRef.current - ) { - setShowValidationDialog(false); - } - break; - } - }; - - const unsubscribe = api.github.onValidationEvent(handleValidationEvent); - return () => unsubscribe(); - }, [currentProject?.path, 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, - options: { showDialog?: boolean; forceRevalidate?: boolean } = {} - ) => { - const { showDialog = true, forceRevalidate = false } = options; - - 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 (unless force revalidate) - const cached = cachedValidations.get(issue.number); - if (cached && showDialog && !forceRevalidate) { - // 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) { @@ -478,37 +115,12 @@ export function GitHubIssuesView() { [currentProject?.path, defaultProfile, currentBranch] ); - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - }; - if (loading) { - return ( -
- -
- ); + return ; } if (error) { - return ( -
-
- -
-

Failed to Load Issues

-

{error}

- -
- ); + return ; } const totalIssues = openIssues.length + closedIssues.length; @@ -523,24 +135,12 @@ export function GitHubIssuesView() { )} > {/* Header */} -
-
-
- -
-
-

Issues

-

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

-
-
- -
+ {/* Issues List */}
@@ -595,239 +195,17 @@ export function GitHubIssuesView() { {/* 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. -

- -
-
-
+ setSelectedIssue(null)} + onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)} + formatDate={formatDate} + /> )} {/* Validation Dialog */} @@ -858,134 +236,3 @@ export function GitHubIssuesView() {
); } - -interface IssueRowProps { - issue: GitHubIssue; - isSelected: boolean; - onClick: () => void; - onOpenExternal: () => void; - formatDate: (date: string) => string; - /** Cached validation for this issue (if any) */ - cachedValidation?: StoredValidation | null; - /** Whether validation is currently running for this issue */ - isValidating?: boolean; -} - -function IssueRow({ - issue, - isSelected, - onClick, - onOpenExternal, - formatDate, - cachedValidation, - isValidating, -}: IssueRowProps) { - // Check if validation exists and calculate staleness - const validationHoursSince = cachedValidation - ? (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60) - : null; - const isValidationStale = validationHoursSince !== null && validationHoursSince > 24; - - // Check if validation is unviewed (exists, not stale, not viewed) - const hasUnviewedValidation = - cachedValidation && !cachedValidation.viewedAt && !isValidationStale; - - // Check if validation has been viewed (exists and was viewed) - const hasViewedValidation = cachedValidation && cachedValidation.viewedAt && !isValidationStale; - 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(', ')} - - )} - - {/* Validating indicator */} - {isValidating && ( - - - Analyzing... - - )} - - {/* Unviewed validation indicator */} - {!isValidating && hasUnviewedValidation && ( - - - Analysis Ready - - )} - - {/* Viewed validation indicator */} - {!isValidating && hasViewedValidation && ( - - - Validated - - )} -
-
- - -
- ); -} diff --git a/apps/ui/src/components/views/github-issues-view/components/index.ts b/apps/ui/src/components/views/github-issues-view/components/index.ts new file mode 100644 index 00000000..b3af9f84 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/index.ts @@ -0,0 +1,3 @@ +export { IssueRow } from './issue-row'; +export { IssueDetailPanel } from './issue-detail-panel'; +export { IssuesListHeader } from './issues-list-header'; diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx new file mode 100644 index 00000000..7969da38 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx @@ -0,0 +1,242 @@ +import { + Circle, + CheckCircle2, + X, + Wand2, + ExternalLink, + Loader2, + CheckCircle, + Clock, + GitPullRequest, + User, + RefreshCw, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Markdown } from '@/components/ui/markdown'; +import { cn } from '@/lib/utils'; +import type { IssueDetailPanelProps } from '../types'; +import { isValidationStale } from '../utils'; + +export function IssueDetailPanel({ + issue, + validatingIssues, + cachedValidations, + onValidateIssue, + onViewCachedValidation, + onOpenInGitHub, + onClose, + onShowRevalidateConfirm, + formatDate, +}: IssueDetailPanelProps) { + const isValidating = validatingIssues.has(issue.number); + const cached = cachedValidations.get(issue.number); + const isStale = cached ? isValidationStale(cached.validatedAt) : false; + + return ( +
+ {/* Detail Header */} +
+
+ {issue.state === 'OPEN' ? ( + + ) : ( + + )} + + #{issue.number} {issue.title} + +
+
+ {(() => { + if (isValidating) { + return ( + + ); + } + + if (cached && !isStale) { + return ( + <> + + + + ); + } + + if (cached && isStale) { + return ( + <> + + + + ); + } + + return ( + + ); + })()} + + +
+
+ + {/* Issue Detail Content */} +
+ {/* Title */} +

{issue.title}

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

No description provided.

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

+ View comments, add reactions, and more on GitHub. +

+ +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx new file mode 100644 index 00000000..bf6496f1 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx @@ -0,0 +1,136 @@ +import { + Circle, + CheckCircle2, + ExternalLink, + Loader2, + CheckCircle, + Sparkles, + GitPullRequest, + User, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { IssueRowProps } from '../types'; +import { isValidationStale } from '../utils'; + +export function IssueRow({ + issue, + isSelected, + onClick, + onOpenExternal, + formatDate, + cachedValidation, + isValidating, +}: IssueRowProps) { + // Check if validation exists and calculate staleness + const validationHoursSince = cachedValidation + ? (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60) + : null; + const isValidationStaleValue = + validationHoursSince !== null && isValidationStale(cachedValidation!.validatedAt); + + // Check if validation is unviewed (exists, not stale, not viewed) + const hasUnviewedValidation = + cachedValidation && !cachedValidation.viewedAt && !isValidationStaleValue; + + // Check if validation has been viewed (exists and was viewed) + const hasViewedValidation = + cachedValidation && cachedValidation.viewedAt && !isValidationStaleValue; + + 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(', ')} + + )} + + {/* Validating indicator */} + {isValidating && ( + + + Analyzing... + + )} + + {/* Unviewed validation indicator */} + {!isValidating && hasUnviewedValidation && ( + + + Analysis Ready + + )} + + {/* Viewed validation indicator */} + {!isValidating && hasViewedValidation && ( + + + Validated + + )} +
+
+ + +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx new file mode 100644 index 00000000..5529b30c --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -0,0 +1,38 @@ +import { CircleDot, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface IssuesListHeaderProps { + openCount: number; + closedCount: number; + refreshing: boolean; + onRefresh: () => void; +} + +export function IssuesListHeader({ + openCount, + closedCount, + refreshing, + onRefresh, +}: IssuesListHeaderProps) { + const totalIssues = openCount + closedCount; + + return ( +
+
+
+ +
+
+

Issues

+

+ {totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`} +

+
+
+ +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/constants.ts b/apps/ui/src/components/views/github-issues-view/constants.ts new file mode 100644 index 00000000..22a6785a --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/constants.ts @@ -0,0 +1 @@ +export const VALIDATION_STALENESS_HOURS = 24; diff --git a/apps/ui/src/components/views/github-issues-view/dialogs/index.ts b/apps/ui/src/components/views/github-issues-view/dialogs/index.ts new file mode 100644 index 00000000..886b09b2 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/dialogs/index.ts @@ -0,0 +1 @@ +export { ValidationDialog } from './validation-dialog'; diff --git a/apps/ui/src/components/views/github-issues-view/validation-dialog.tsx b/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx similarity index 99% rename from apps/ui/src/components/views/github-issues-view/validation-dialog.tsx rename to apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx index 1f8374e9..cc989c8c 100644 --- a/apps/ui/src/components/views/github-issues-view/validation-dialog.tsx +++ b/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx @@ -152,7 +152,7 @@ export function ValidationDialog({ {/* Bug Confirmed Badge */} {validationResult.bugConfirmed && (
- + Bug Confirmed in Codebase
)} diff --git a/apps/ui/src/components/views/github-issues-view/hooks/index.ts b/apps/ui/src/components/views/github-issues-view/hooks/index.ts new file mode 100644 index 00000000..c3417416 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/index.ts @@ -0,0 +1,2 @@ +export { useGithubIssues } from './use-github-issues'; +export { useIssueValidation } from './use-issue-validation'; diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts new file mode 100644 index 00000000..27e33488 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getElectronAPI, GitHubIssue } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; + +export function useGithubIssues() { + const { currentProject } = useAppStore(); + const [openIssues, setOpenIssues] = useState([]); + const [closedIssues, setClosedIssues] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + 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]); + + const refresh = useCallback(() => { + setRefreshing(true); + fetchIssues(); + }, [fetchIssues]); + + return { + openIssues, + closedIssues, + loading, + refreshing, + error, + refresh, + }; +} diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts new file mode 100644 index 00000000..25665757 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts @@ -0,0 +1,330 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + getElectronAPI, + GitHubIssue, + IssueValidationResult, + IssueValidationEvent, + StoredValidation, +} from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import { toast } from 'sonner'; +import { isValidationStale } from '../utils'; + +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, validationModel, muteDoneSound } = useAppStore(); + const [validatingIssues, setValidatingIssues] = useState>(new Set()); + const [cachedValidations, setCachedValidations] = useState>( + new Map() + ); + const audioRef = useRef(null); + // 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) { + console.error('[GitHubIssuesView] 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) { + console.error('[GitHubIssuesView] 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: { showDialog?: boolean; forceRevalidate?: boolean } = {} + ) => { + const { showDialog = true, forceRevalidate = false } = options; + + 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 (unless force revalidate) + const cached = cachedValidations.get(issue.number); + if (cached && showDialog && !forceRevalidate) { + // Check if validation is stale + if (!isValidationStale(cached.validatedAt)) { + // Show cached result directly + onValidationResultChange(cached.result); + onShowValidationDialogChange(true); + return; + } + } + + // Start async validation + onValidationResultChange(null); + if (showDialog) { + onShowValidationDialogChange(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) { + onShowValidationDialogChange(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) { + onShowValidationDialogChange(false); + } + } + }, + [ + currentProject?.path, + validatingIssues, + cachedValidations, + validationModel, + 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) { + 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, + onValidationResultChange, + onShowValidationDialogChange, + ] + ); + + return { + validatingIssues, + cachedValidations, + handleValidateIssue, + handleViewCachedValidation, + }; +} diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts new file mode 100644 index 00000000..9fce6d53 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -0,0 +1,28 @@ +import type { GitHubIssue, StoredValidation } from '@/lib/electron'; + +export interface IssueRowProps { + issue: GitHubIssue; + isSelected: boolean; + onClick: () => void; + onOpenExternal: () => void; + formatDate: (date: string) => string; + /** Cached validation for this issue (if any) */ + cachedValidation?: StoredValidation | null; + /** Whether validation is currently running for this issue */ + isValidating?: boolean; +} + +export interface IssueDetailPanelProps { + issue: GitHubIssue; + validatingIssues: Set; + cachedValidations: Map; + onValidateIssue: ( + issue: GitHubIssue, + options?: { showDialog?: boolean; forceRevalidate?: boolean } + ) => Promise; + onViewCachedValidation: (issue: GitHubIssue) => Promise; + onOpenInGitHub: (url: string) => void; + onClose: () => void; + onShowRevalidateConfirm: () => void; + formatDate: (date: string) => string; +} diff --git a/apps/ui/src/components/views/github-issues-view/utils.ts b/apps/ui/src/components/views/github-issues-view/utils.ts new file mode 100644 index 00000000..ad313317 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/utils.ts @@ -0,0 +1,33 @@ +import type { IssueComplexity } from '@/lib/electron'; +import { VALIDATION_STALENESS_HOURS } from './constants'; + +/** + * Map issue complexity to feature priority. + * Lower complexity issues get higher priority (1 = high, 2 = medium). + */ +export 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 + } +} + +export function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export function isValidationStale(validatedAt: string): boolean { + const hoursSinceValidation = (Date.now() - new Date(validatedAt).getTime()) / (1000 * 60 * 60); + return hoursSinceValidation > VALIDATION_STALENESS_HOURS; +}