diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index 7a0b06f3..ac1cd6a6 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -109,6 +109,7 @@ export interface PRReviewResult { // Track if findings have been posted to GitHub (enables follow-up review) hasPostedFindings?: boolean; postedFindingIds?: string[]; + postedAt?: string; } /** @@ -205,6 +206,7 @@ function getReviewResult(project: Project, prNumber: number): PRReviewResult | n // Track posted findings for follow-up review eligibility hasPostedFindings: data.has_posted_findings ?? false, postedFindingIds: data.posted_finding_ids ?? [], + postedAt: data.posted_at, }; } catch { // File doesn't exist or couldn't be read @@ -708,6 +710,7 @@ export function registerPRHandlers( const newPostedIds = findings.map(f => f.id); const existingPostedIds = data.posted_finding_ids || []; data.posted_finding_ids = [...new Set([...existingPostedIds, ...newPostedIds])]; + data.posted_at = new Date().toISOString(); fs.writeFileSync(reviewPath, JSON.stringify(data, null, 2), 'utf-8'); debugLog('Updated review result with review ID and posted findings', { prNumber, reviewId, postedCount: newPostedIds.length }); } catch { diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index 4fb5ff1e..9b9e0fc4 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -325,6 +325,7 @@ export interface PRReviewResult { // Track if findings have been posted to GitHub (enables follow-up review) hasPostedFindings?: boolean; postedFindingIds?: string[]; + postedAt?: string; } /** diff --git a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx index fd8785ac..56b77117 100644 --- a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx +++ b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx @@ -1,9 +1,11 @@ import { useCallback } from 'react'; import { GitPullRequest, RefreshCw, ExternalLink, Settings } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { useProjectStore } from '../../stores/project-store'; -import { useGitHubPRs } from './hooks'; -import { PRList, PRDetail } from './components'; +import { useGitHubPRs, usePRFiltering } from './hooks'; +import { PRList, PRDetail, PRFilterBar } from './components'; import { Button } from '../ui/button'; +import { ResizablePanels } from '../ui/resizable-panels'; interface GitHubPRsProps { onOpenSettings?: () => void; @@ -11,23 +13,25 @@ interface GitHubPRsProps { function NotConnectedState({ error, - onOpenSettings + onOpenSettings, + t }: { error: string | null; onOpenSettings?: () => void; + t: (key: string) => string; }) { return (
-

GitHub Not Connected

+

{t('prReview.notConnected')}

- {error || 'Connect your GitHub account to view and review pull requests.'} + {error || t('prReview.connectPrompt')}

{onOpenSettings && ( )}
@@ -47,6 +51,7 @@ function EmptyState({ message }: { message: string }) { } export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) { + const { t } = useTranslation('common'); const projects = useProjectStore((state) => state.projects); const selectedProjectId = useProjectStore((state) => state.selectedProjectId); const selectedProject = projects.find((p) => p.id === selectedProjectId); @@ -77,6 +82,18 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) { const selectedPR = prs.find(pr => pr.number === selectedPRNumber); + // PR filtering + const { + filteredPRs, + contributors, + filters, + setSearchQuery, + setContributors, + setStatuses, + clearFilters, + hasActiveFilters, + } = usePRFiltering(prs, getReviewStateForPR); + const handleRunReview = useCallback(() => { if (selectedPRNumber) { runReview(selectedPRNumber); @@ -129,7 +146,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) { // Not connected state if (!isConnected) { - return ; + return ; } return ( @@ -139,7 +156,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {

- Pull Requests + {t('prReview.pullRequests')}

{repoFullName && ( )} - {prs.length} open + {prs.length} {t('prReview.open')}
- {/* Content */} -
- {/* PR List */} -
- -
- - {/* PR Detail */} -
- {selectedPR ? ( + {/* Content - Resizable split panels */} + + + +
+ } + rightPanel={ + selectedPR ? ( ) : ( - - )} -
- + + ) + } + /> ); } diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx index af896d67..16318a2b 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { ExternalLink, User, - Users, Clock, GitBranch, FileDiff, @@ -17,6 +17,11 @@ import { MessageSquare, AlertTriangle, CheckCheck, + ChevronRight, + ChevronDown, + Circle, + CircleDot, + Play } from 'lucide-react'; import { Badge } from '../../ui/badge'; import { Button } from '../../ui/button'; @@ -24,7 +29,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; import { ScrollArea } from '../../ui/scroll-area'; import { Progress } from '../../ui/progress'; import { ReviewFindings } from './ReviewFindings'; -import type { PRData, PRReviewResult, PRReviewProgress, PRReviewFinding } from '../hooks/useGitHubPRs'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../ui/collapsible'; +import { cn } from '../../../lib/utils'; +import type { PRData, PRReviewResult, PRReviewProgress } from '../hooks/useGitHubPRs'; import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api'; interface PRDetailProps { @@ -63,6 +70,256 @@ function getStatusColor(status: PRReviewResult['overallStatus']): string { } } +// Compact Tree View for Review Process +function ReviewStatusTree({ + status, + isReviewing, + reviewResult, + postedCount, + onRunReview, + onRunFollowupReview, + onCancelReview, + newCommitsCheck, + lastPostedAt +}: { + status: 'not_reviewed' | 'reviewed_pending_post' | 'waiting_for_changes' | 'ready_to_merge' | 'needs_attention' | 'ready_for_followup' | 'followup_issues_remain'; + isReviewing: boolean; + reviewResult: PRReviewResult | null; + postedCount: number; + onRunReview: () => void; + onRunFollowupReview: () => void; + onCancelReview: () => void; + newCommitsCheck: NewCommitsCheck | null; + lastPostedAt?: number | null; +}) { + const { t } = useTranslation('common'); + const [isOpen, setIsOpen] = useState(true); + + // If not reviewed, show simple status + if (status === 'not_reviewed' && !isReviewing) { + return ( +
+
+
+ {t('prReview.notReviewed')} +
+ +
+ ); + } + + // Determine steps for the tree + const steps: { id: string; label: string; status: string; date?: string | null; action?: React.ReactNode }[] = []; + + // Step 1: Start + steps.push({ + id: 'start', + label: t('prReview.reviewStarted'), + status: 'completed', + date: reviewResult?.reviewedAt || new Date().toISOString() + }); + + // Step 2: AI Analysis + if (isReviewing) { + steps.push({ + id: 'analysis', + label: t('prReview.analysisInProgress'), + status: 'current', + date: null + }); + } else if (reviewResult) { + steps.push({ + id: 'analysis', + label: t('prReview.analysisComplete', { count: reviewResult.findings.length }), + status: 'completed', + date: reviewResult.reviewedAt + }); + } + + // Step 3: Posting + if (postedCount > 0 || reviewResult?.hasPostedFindings) { + steps.push({ + id: 'posted', + label: t('prReview.findingsPostedToGitHub'), + status: 'completed', + date: reviewResult?.postedAt || (lastPostedAt ? new Date(lastPostedAt).toISOString() : null) + }); + } else if (reviewResult && reviewResult.findings.length > 0) { + steps.push({ + id: 'posted', + label: t('prReview.pendingPost'), + status: 'pending', + date: null + }); + } + + // Step 4: Follow-up + if (newCommitsCheck?.hasNewCommits) { + steps.push({ + id: 'new_commits', + label: t('prReview.newCommits', { count: newCommitsCheck.newCommitCount }), + status: 'alert', + date: null + }); + steps.push({ + id: 'followup', + label: t('prReview.readyForFollowup'), + status: 'pending', + action: ( + + ) + }); + } + + return ( + + {/* Header / Status Bar */} +
+
+
+ + {isReviewing ? t('prReview.aiReviewInProgress') : + status === 'ready_to_merge' ? t('prReview.readyToMerge') : + status === 'waiting_for_changes' ? t('prReview.waitingForChanges') : + status === 'reviewed_pending_post' ? t('prReview.reviewComplete') : + status === 'ready_for_followup' ? t('prReview.readyForFollowup') : + t('prReview.reviewStatus')} + +
+
+ {isReviewing && ( + + )} + + + +
+
+ + {/* Collapsible Tree */} + +
+
+ {steps.map((step) => ( +
+ {/* Node Dot */} +
+ {step.status === 'completed' ? : + step.status === 'current' ? : + } +
+ +
+
+ + {step.label} + + {step.action} +
+ {step.date && ( +
+ {formatDate(step.date)} +
+ )} +
+
+ ))} +
+
+
+ + ); +} + +// Modern Header Component +function PRHeader({ pr }: { pr: PRData }) { + const { t } = useTranslation('common'); + return ( +
+
+
+ + {pr.state} + + #{pr.number} +
+
+
+ +

{pr.title}

+ +
+
+
+ +
+ {pr.author.login} +
+ +
+ + {formatDate(pr.createdAt)} +
+ +
+ + {pr.headRefName} + + {pr.baseRefName} +
+ +
+
+ + {pr.changedFiles} + {t('prReview.files')} +
+
+ +{pr.additions} + -{pr.deletions} +
+
+
+
+ ); +} + export function PRDetail({ pr, reviewResult, @@ -77,6 +334,7 @@ export function PRDetail({ onMergePR, onAssignPR: _onAssignPR, }: PRDetailProps) { + const { t } = useTranslation('common'); // Selection state for findings const [selectedFindingIds, setSelectedFindingIds] = useState>(new Set()); const [postedFindingIds, setPostedFindingIds] = useState>(new Set()); @@ -330,230 +588,98 @@ export function PRDetail({ return ( -
- {/* Header */} -
-
-
- - Open - - #{pr.number} -
- -
-

{pr.title}

-
+
+ + {/* Refactored Header */} + - {/* Meta */} -
-
- - {pr.author.login} -
-
- - {formatDate(pr.createdAt)} -
-
- - {pr.headRefName} → {pr.baseRefName} -
- {pr.assignees && pr.assignees.length > 0 && ( -
- - {pr.assignees.map(a => a.login).join(', ')} -
- )} -
+ {/* Review Status & Actions */} + - {/* Stats */} -
- - - {pr.changedFiles} files - - +{pr.additions} - -{pr.deletions} -
- - {/* Actions */} -
-
- {/* Show Follow-up Review button if there are new commits since last review */} - {newCommitsCheck?.hasNewCommits && !isReviewing ? ( - - ) : ( - - )} - {isReviewing && ( - - )} - {reviewResult && reviewResult.success && selectedCount > 0 && !isReviewing && ( - - )} - {/* Success message */} - {postSuccess && ( -
- - Posted {postSuccess.count} finding{postSuccess.count !== 1 ? 's' : ''} to GitHub -
- )} -
- - {/* Approval and Merge buttons */} - {reviewResult && reviewResult.success && isReadyToMerge && ( -
- - -
- )} -
- - {/* PR Review Status Banner */} - - -
-
- {prStatus.icon} -
-
-
{prStatus.label}
-
{prStatus.description}
-
- {prStatus.status === 'ready_for_followup' && ( - - )} - {prStatus.status === 'waiting_for_changes' && newCommitsCheck?.hasNewCommits && ( - - - {newCommitsCheck.newCommitCount} new commit{newCommitsCheck.newCommitCount !== 1 ? 's' : ''} - - )} -
-
-
+ )} + + {isReadyToMerge && ( + <> + + + + )} + + {postSuccess && ( +
+ + {t('prReview.postedFindings', { count: postSuccess.count })} +
+ )} +
+ )} {/* Review Progress */} {reviewProgress && ( - - -
-
- {reviewProgress.message} - {reviewProgress.progress}% -
- -
-
-
+
+
+ {reviewProgress.message} + {reviewProgress.progress}% +
+ +
)} - {/* Review Result */} + {/* Review Result / Findings */} {reviewResult && reviewResult.success && ( - - - + + + {reviewResult.isFollowupReview ? ( - + ) : ( - + )} - {reviewResult.isFollowupReview ? 'Follow-up Review' : 'AI Review Result'} + {reviewResult.isFollowupReview ? 'Follow-up Review Details' : 'AI Analysis Results'} {reviewResult.overallStatus === 'approve' && 'Approve'} @@ -562,32 +688,34 @@ export function PRDetail({ - + {/* Follow-up Review Resolution Status */} {reviewResult.isFollowupReview && ( -
+
{(reviewResult.resolvedFindings?.length ?? 0) > 0 && ( - - - {reviewResult.resolvedFindings?.length} resolved + + + {t('prReview.resolved', { count: reviewResult.resolvedFindings?.length ?? 0 })} )} {(reviewResult.unresolvedFindings?.length ?? 0) > 0 && ( - - - {reviewResult.unresolvedFindings?.length} still open + + + {t('prReview.stillOpen', { count: reviewResult.unresolvedFindings?.length ?? 0 })} )} {(reviewResult.newFindingsSinceLastReview?.length ?? 0) > 0 && ( - - - {reviewResult.newFindingsSinceLastReview?.length} new issue{reviewResult.newFindingsSinceLastReview?.length !== 1 ? 's' : ''} + + + {t('prReview.newIssue', { count: reviewResult.newFindingsSinceLastReview?.length ?? 0 })} )}
)} -

{reviewResult.summary}

+
+ {reviewResult.summary} +
{/* Interactive Findings with Selection */} - - {reviewResult.reviewedAt && ( -

- Reviewed: {formatDate(reviewResult.reviewedAt)} - {reviewResult.reviewedCommitSha && ( - <> at commit {reviewResult.reviewedCommitSha.substring(0, 7)} - )} -

- )} )} {/* Review Error */} {reviewResult && !reviewResult.success && reviewResult.error && ( - - -
- - {reviewResult.error} + + +
+ +
+

{t('prReview.reviewFailed')}

+

{reviewResult.error}

+
@@ -624,47 +746,20 @@ export function PRDetail({ {/* Description */} - Description + {t('prReview.description')} - - {pr.body ? ( -
-                {pr.body}
-              
- ) : ( -

- No description provided. -

- )} + + + {pr.body ? ( +
+                  {pr.body}
+                
+ ) : ( +

{t('prReview.noDescription')}

+ )} +
- - {/* Changed Files */} - {pr.files && pr.files.length > 0 && ( - - - Changed Files ({pr.files.length}) - - -
- {pr.files.map((file) => ( -
- - {file.path} - -
- +{file.additions} - -{file.deletions} -
-
- ))} -
-
-
- )}
); diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRFilterBar.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRFilterBar.tsx new file mode 100644 index 00000000..f2f80e8a --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/components/PRFilterBar.tsx @@ -0,0 +1,410 @@ +/** + * Filter bar for GitHub PRs list + * Grid layout: Contributors (3) | Status (3) | Search (8) + * Multi-select dropdowns with visible chip selections + */ + +import { useState, useMemo, useRef, useCallback } from 'react'; +import { + Search, + Users, + Sparkles, + CheckCircle2, + Send, + AlertCircle, + CheckCheck, + RefreshCw, + X, + Filter, + Check +} from 'lucide-react'; +import { Input } from '../../ui/input'; +import { Badge } from '../../ui/badge'; +import { Button } from '../../ui/button'; +import { Separator } from '../../ui/separator'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../../ui/dropdown-menu'; +import { useTranslation } from 'react-i18next'; +import type { PRFilterState, PRStatusFilter } from '../hooks/usePRFiltering'; +import { cn } from '../../../lib/utils'; + +interface PRFilterBarProps { + filters: PRFilterState; + contributors: string[]; + hasActiveFilters: boolean; + onSearchChange: (query: string) => void; + onContributorsChange: (contributors: string[]) => void; + onStatusesChange: (statuses: PRStatusFilter[]) => void; + onClearFilters: () => void; +} + +// Status options +const STATUS_OPTIONS: Array<{ + value: PRStatusFilter; + labelKey: string; + icon: typeof Sparkles; + color: string; + bgColor: string; +}> = [ + { value: 'not_reviewed', labelKey: 'prReview.notReviewed', icon: Sparkles, color: 'text-slate-500', bgColor: 'bg-slate-500/20' }, + { value: 'reviewed', labelKey: 'prReview.reviewed', icon: CheckCircle2, color: 'text-blue-400', bgColor: 'bg-blue-500/20' }, + { value: 'posted', labelKey: 'prReview.posted', icon: Send, color: 'text-purple-400', bgColor: 'bg-purple-500/20' }, + { value: 'changes_requested', labelKey: 'prReview.changesRequested', icon: AlertCircle, color: 'text-red-400', bgColor: 'bg-red-500/20' }, + { value: 'ready_to_merge', labelKey: 'prReview.readyToMerge', icon: CheckCheck, color: 'text-emerald-400', bgColor: 'bg-emerald-500/20' }, + { value: 'ready_for_followup', labelKey: 'prReview.readyForFollowup', icon: RefreshCw, color: 'text-cyan-400', bgColor: 'bg-cyan-500/20' }, +]; + +/** + * Modern Filter Dropdown Component + */ +function FilterDropdown({ + title, + icon: Icon, + items, + selected, + onChange, + renderItem, + renderTrigger, + searchable = false, + searchPlaceholder, + selectedCountLabel, + noResultsLabel, + clearLabel, +}: { + title: string; + icon: typeof Users; + items: T[]; + selected: T[]; + onChange: (selected: T[]) => void; + renderItem?: (item: T) => React.ReactNode; + renderTrigger?: (selected: T[]) => React.ReactNode; + searchable?: boolean; + searchPlaceholder?: string; + selectedCountLabel?: string; + noResultsLabel?: string; + clearLabel?: string; +}) { + const [searchTerm, setSearchTerm] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + + const toggleItem = useCallback((item: T) => { + if (selected.includes(item)) { + onChange(selected.filter((s) => s !== item)); + } else { + onChange([...selected, item]); + } + }, [selected, onChange]); + + const filteredItems = useMemo(() => { + if (!searchTerm) return items; + return items.filter(item => + item.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [items, searchTerm]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (filteredItems.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex(prev => + prev < filteredItems.length - 1 ? prev + 1 : 0 + ); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex(prev => + prev > 0 ? prev - 1 : filteredItems.length - 1 + ); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < filteredItems.length) { + toggleItem(filteredItems[focusedIndex]); + } + break; + case 'Escape': + setIsOpen(false); + break; + } + }, [filteredItems, focusedIndex, toggleItem]); + + return ( + { + setIsOpen(open); + if (!open) { + setSearchTerm(''); + setFocusedIndex(-1); + } + }}> + + + + +
+
+ {title} +
+ {searchable && ( +
+ + setSearchTerm(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ )} +
+ +
+ {filteredItems.length === 0 ? ( +
+ {noResultsLabel} +
+ ) : ( + filteredItems.map((item, index) => { + const isSelected = selected.includes(item); + const isFocused = index === focusedIndex; + return ( +
{ itemRefs.current[index] = el; }} + role="option" + aria-selected={isSelected} + className={cn( + "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + isSelected && "bg-accent/50", + isFocused && "ring-2 ring-primary/50 bg-accent" + )} + onClick={(e) => { + e.preventDefault(); + toggleItem(item); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleItem(item); + } + }} + tabIndex={-1} + > +
+ +
+ {renderItem ? renderItem(item) : item} +
+ ); + }) + )} +
+ + {selected.length > 0 && ( +
+ +
+ )} +
+
+ ); +} + +export function PRFilterBar({ + filters, + contributors, + hasActiveFilters, + onSearchChange, + onContributorsChange, + onStatusesChange, + onClearFilters, +}: PRFilterBarProps) { + const { t } = useTranslation('common'); + + // Get status option by value + const getStatusOption = (value: PRStatusFilter) => + STATUS_OPTIONS.find((opt) => opt.value === value); + + return ( +
+
+ {/* Search Input - Flexible width */} +
+ + onSearchChange(e.target.value)} + className="h-8 pl-9 bg-background/50 focus:bg-background transition-colors" + /> + {filters.searchQuery && ( + + )} +
+ + + + {/* Contributors Filter */} +
+ ( +
+
+ + {contributor.slice(0, 2).toUpperCase()} + +
+ {contributor} +
+ )} + /> +
+ + {/* Status Filter */} +
+ opt.value)} + selected={filters.statuses} + onChange={onStatusesChange} + selectedCountLabel={t('prReview.selectedCount', { count: filters.statuses.length })} + noResultsLabel={t('prReview.noResultsFound')} + clearLabel={t('prReview.clearFilters')} + renderItem={(status) => { + const option = getStatusOption(status); + if (!option) return null; + const Icon = option.icon; + return ( +
+
+ +
+ {t(option.labelKey)} +
+ ); + }} + renderTrigger={(selected) => ( + selected.map(status => { + const option = getStatusOption(status); + if (!option) return null; + const Icon = option.icon; + return ( + + + {t(option.labelKey)} + + ); + }) + )} + /> +
+ + {/* Reset All */} + {hasActiveFilters && ( + + )} +
+
+ ); +} + diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx index 4b95e53e..1a311924 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx @@ -1,4 +1,4 @@ -import { GitPullRequest, User, Clock, FileDiff, Loader2, CheckCircle2, AlertCircle, MessageSquare, RefreshCw } from 'lucide-react'; +import { GitPullRequest, User, Clock, FileDiff, Loader2, CheckCircle2, AlertCircle, MessageSquare, RefreshCw, Send } from 'lucide-react'; import { ScrollArea } from '../../ui/scroll-area'; import { Badge } from '../../ui/badge'; import { cn } from '../../../lib/utils'; @@ -104,7 +104,6 @@ interface PRListProps { selectedPRNumber: number | null; isLoading: boolean; error: string | null; - activePRReviews: number[]; getReviewStateForPR: (prNumber: number) => PRReviewInfo | null; onSelectPR: (prNumber: number) => void; } @@ -129,7 +128,7 @@ function formatDate(dateString: string): string { return date.toLocaleDateString(); } -export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReviews, getReviewStateForPR, onSelectPR }: PRListProps) { +export function PRList({ prs, selectedPRNumber, isLoading, error, getReviewStateForPR, onSelectPR }: PRListProps) { const { t } = useTranslation('common'); if (isLoading && prs.length === 0) { @@ -137,7 +136,7 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReview
-

Loading pull requests...

+

{t('prReview.loadingPRs')}

); @@ -158,7 +157,7 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReview
-

No open pull requests

+

{t('prReview.noOpenPRs')}

); @@ -200,12 +199,19 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReview {!isReviewingPR && hasReviewResult && reviewState?.result && ( <> {/* Show "Reviewed" if AI review is complete but not yet posted to GitHub */} - {!reviewState.result.reviewId && ( + {!reviewState.result.reviewId && !reviewState.result.hasPostedFindings && ( {t('prReview.reviewed')} )} + {/* Show "Posted" when findings posted to GitHub but no full review ID */} + {!reviewState.result.reviewId && reviewState.result.hasPostedFindings && ( + + + {t('prReview.posted')} + + )} {/* Show actual status only after posted to GitHub (has reviewId) */} {reviewState.result.reviewId && reviewState.result.overallStatus === 'approve' && ( diff --git a/apps/frontend/src/renderer/components/github-prs/components/index.ts b/apps/frontend/src/renderer/components/github-prs/components/index.ts index 66434989..bac9986b 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/index.ts +++ b/apps/frontend/src/renderer/components/github-prs/components/index.ts @@ -1,2 +1,3 @@ export { PRList } from './PRList'; export { PRDetail } from './PRDetail'; +export { PRFilterBar } from './PRFilterBar'; diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/index.ts b/apps/frontend/src/renderer/components/github-prs/hooks/index.ts index f051c89a..80d29999 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/index.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/index.ts @@ -1,4 +1,6 @@ export { useGitHubPRs } from './useGitHubPRs'; +export { usePRFiltering } from './usePRFiltering'; +export type { PRFilterState, PRStatusFilter } from './usePRFiltering'; export type { PRData, PRReviewFinding, diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts index 329e3f0b..fe91183d 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -210,7 +210,15 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { if (!projectId) return false; try { - return await window.electronAPI.github.postPRReview(projectId, prNumber, selectedFindingIds); + const success = await window.electronAPI.github.postPRReview(projectId, prNumber, selectedFindingIds); + if (success) { + // Reload review result to get updated postedAt and finding status + const result = await window.electronAPI.github.getPRReview(projectId, prNumber); + if (result) { + usePRReviewStore.getState().setPRReviewResult(projectId, result); + } + } + return success; } catch (err) { setError(err instanceof Error ? err.message : 'Failed to post review'); return false; diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts b/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts new file mode 100644 index 00000000..9bdf77e5 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts @@ -0,0 +1,174 @@ +/** + * Hook for filtering and searching GitHub PRs + */ + +import { useMemo, useState, useCallback } from 'react'; +import type { PRData, PRReviewResult } from '../../../../preload/api/modules/github-api'; +import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api'; + +export type PRStatusFilter = + | 'not_reviewed' + | 'reviewed' + | 'posted' + | 'changes_requested' + | 'ready_to_merge' + | 'ready_for_followup'; + +export interface PRFilterState { + searchQuery: string; + contributors: string[]; + statuses: PRStatusFilter[]; +} + +interface PRReviewInfo { + isReviewing: boolean; + result: PRReviewResult | null; + newCommitsCheck?: NewCommitsCheck | null; +} + +const DEFAULT_FILTERS: PRFilterState = { + searchQuery: '', + contributors: [], + statuses: [], +}; + +/** + * Determine the computed status of a PR based on its review state + */ +function getPRComputedStatus( + reviewInfo: PRReviewInfo | null +): PRStatusFilter { + if (!reviewInfo?.result) { + return 'not_reviewed'; + } + + const result = reviewInfo.result; + const hasPosted = Boolean(result.reviewId) || Boolean(result.hasPostedFindings); + const hasBlockingFindings = result.findings?.some( + f => f.severity === 'critical' || f.severity === 'high' + ); + const hasNewCommits = reviewInfo.newCommitsCheck?.hasNewCommits; + + // Check for ready for follow-up first (highest priority after posting) + if (hasPosted && hasNewCommits) { + return 'ready_for_followup'; + } + + // Posted with blocking findings + if (hasPosted && hasBlockingFindings) { + return 'changes_requested'; + } + + // Posted without blocking findings + if (hasPosted) { + return 'ready_to_merge'; + } + + // Has review result but not yet posted to GitHub + // Note: 'posted' is not returned here - it's a meta-filter that matches + // any posted state (ready_to_merge, changes_requested, ready_for_followup) + // and is handled specially in the filter logic below + return 'reviewed'; +} + +export function usePRFiltering( + prs: PRData[], + getReviewStateForPR: (prNumber: number) => PRReviewInfo | null +) { + const [filters, setFiltersState] = useState(DEFAULT_FILTERS); + + // Derive unique contributors from PRs + const contributors = useMemo(() => { + const authorSet = new Set(); + prs.forEach(pr => { + if (pr.author?.login) { + authorSet.add(pr.author.login); + } + }); + return Array.from(authorSet).sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()) + ); + }, [prs]); + + // Filter PRs based on current filters + const filteredPRs = useMemo(() => { + return prs.filter(pr => { + // Search filter - matches title or body + if (filters.searchQuery) { + const query = filters.searchQuery.toLowerCase(); + const matchesTitle = pr.title.toLowerCase().includes(query); + const matchesBody = pr.body?.toLowerCase().includes(query); + const matchesNumber = pr.number.toString().includes(query); + if (!matchesTitle && !matchesBody && !matchesNumber) { + return false; + } + } + + // Contributors filter (multi-select) + if (filters.contributors.length > 0) { + const authorLogin = pr.author?.login; + if (!authorLogin || !filters.contributors.includes(authorLogin)) { + return false; + } + } + + // Status filter (multi-select) + if (filters.statuses.length > 0) { + const reviewInfo = getReviewStateForPR(pr.number); + const computedStatus = getPRComputedStatus(reviewInfo); + + // Check if PR matches any of the selected statuses + const matchesStatus = filters.statuses.some(status => { + // Special handling: 'posted' should match any posted state + if (status === 'posted') { + const hasPosted = reviewInfo?.result?.reviewId || reviewInfo?.result?.hasPostedFindings; + return hasPosted; + } + return computedStatus === status; + }); + + if (!matchesStatus) { + return false; + } + } + + return true; + }); + }, [prs, filters, getReviewStateForPR]); + + // Filter setters + const setSearchQuery = useCallback((query: string) => { + setFiltersState(prev => ({ ...prev, searchQuery: query })); + }, []); + + const setContributors = useCallback((contributors: string[]) => { + setFiltersState(prev => ({ ...prev, contributors })); + }, []); + + const setStatuses = useCallback((statuses: PRStatusFilter[]) => { + setFiltersState(prev => ({ ...prev, statuses })); + }, []); + + const clearFilters = useCallback(() => { + setFiltersState(DEFAULT_FILTERS); + }, []); + + const hasActiveFilters = useMemo(() => { + return ( + filters.searchQuery !== '' || + filters.contributors.length > 0 || + filters.statuses.length > 0 + ); + }, [filters]); + + return { + filteredPRs, + contributors, + filters, + setSearchQuery, + setContributors, + setStatuses, + clearFilters, + hasActiveFilters, + }; +} diff --git a/apps/frontend/src/renderer/components/ui/resizable-panels.tsx b/apps/frontend/src/renderer/components/ui/resizable-panels.tsx new file mode 100644 index 00000000..198abcfe --- /dev/null +++ b/apps/frontend/src/renderer/components/ui/resizable-panels.tsx @@ -0,0 +1,164 @@ +/** + * ResizablePanels - A split panel layout with a draggable divider + * + * Features: + * - Smooth drag-to-resize functionality + * - Min/max width constraints + * - Persists width to localStorage + * - Visual feedback on hover and drag + */ + +import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'; +import { cn } from '../../lib/utils'; + +interface ResizablePanelsProps { + leftPanel: ReactNode; + rightPanel: ReactNode; + defaultLeftWidth?: number; // percentage, default 50 + minLeftWidth?: number; // percentage, default 30 + maxLeftWidth?: number; // percentage, default 70 + storageKey?: string; // localStorage key for persistence + className?: string; +} + +export function ResizablePanels({ + leftPanel, + rightPanel, + defaultLeftWidth = 50, + minLeftWidth = 30, + maxLeftWidth = 70, + storageKey, + className, +}: ResizablePanelsProps) { + // Load initial width from storage or use default + const [leftWidth, setLeftWidth] = useState(() => { + if (storageKey) { + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + const parsed = parseFloat(stored); + if (!isNaN(parsed) && parsed >= minLeftWidth && parsed <= maxLeftWidth) { + return parsed; + } + } + } catch { + // localStorage may be unavailable (e.g., private browsing) + } + } + return defaultLeftWidth; + }); + + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + // Save to storage when width changes (debounced by only saving when not dragging) + useEffect(() => { + if (storageKey && !isDragging) { + try { + localStorage.setItem(storageKey, leftWidth.toString()); + } catch { + // localStorage may be unavailable (e.g., private browsing, quota exceeded) + } + } + }, [leftWidth, storageKey, isDragging]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + // Guard against division by zero when container has no width + if (rect.width <= 0) return; + const newWidth = ((e.clientX - rect.left) / rect.width) * 100; + const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth)); + setLeftWidth(clampedWidth); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!containerRef.current || e.touches.length === 0) return; + + const rect = containerRef.current.getBoundingClientRect(); + if (rect.width <= 0) return; + const touch = e.touches[0]; + const newWidth = ((touch.clientX - rect.left) / rect.width) * 100; + const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth)); + setLeftWidth(clampedWidth); + }; + + const handleTouchEnd = () => { + setIsDragging(false); + }; + + // Add user-select: none to body during drag to prevent text selection + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd); + + return () => { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + }; + }, [isDragging, minLeftWidth, maxLeftWidth]); + + return ( +
+ {/* Left panel */} +
+ {leftPanel} +
+ + {/* Resizable divider */} +
+ {/* Wider invisible hit area for easier grabbing */} +
+
+ + {/* Right panel */} +
+ {rightPanel} +
+
+ ); +} diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index 32437e00..c89b058a 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -97,7 +97,59 @@ "commented": "Commented", "readyForFollowup": "Ready for Follow-up", "readyToMerge": "Ready to Merge", - "pendingPost": "Pending Post" + "pendingPost": "Pending Post", + "posted": "Posted", + "notReviewed": "Not Reviewed", + "allStatuses": "All statuses", + "allContributors": "All contributors", + "searchPlaceholder": "Search PRs...", + "contributors": "Contributors", + "contributorsSelected": "Contributors ({{count}})", + "status": "Status", + "filters": "Filters", + "clearFilters": "Clear", + "clearSearch": "Clear search", + "searchContributors": "Search contributors...", + "selectedCount": "{{count}} selected", + "noResultsFound": "No results found", + "reset": "Reset", + "pullRequests": "Pull Requests", + "open": "open", + "selectPRToView": "Select a pull request to view details", + "loadingPRs": "Loading pull requests...", + "noOpenPRs": "No open pull requests", + "notConnected": "GitHub Not Connected", + "connectPrompt": "Connect your GitHub account to view and review pull requests.", + "openSettings": "Open Settings", + "runAIReview": "Run AI Review", + "reviewStarted": "Review Started", + "analysisInProgress": "AI Analysis in Progress...", + "analysisComplete": "Analysis Complete ({{count}} findings)", + "findingsPostedToGitHub": "Findings Posted to GitHub", + "newCommits": "{{count}} New Commits", + "runFollowup": "Run Follow-up", + "aiReviewInProgress": "AI Review in Progress", + "waitingForChanges": "Waiting for Changes", + "reviewComplete": "Review Complete", + "reviewStatus": "Review Status", + "files": "files", + "filesChanged": "{{count}} files changed", + "posting": "Posting...", + "postFindings": "Post {{count}} Finding", + "postFindings_plural": "Post {{count}} Findings", + "approve": "Approve", + "merge": "Merge", + "postedFindings": "Posted {{count}} finding", + "postedFindings_plural": "Posted {{count}} findings", + "resolved": "{{count}} resolved", + "resolved_plural": "{{count}} resolved", + "stillOpen": "{{count}} still open", + "stillOpen_plural": "{{count}} still open", + "newIssue": "{{count}} new issue", + "newIssue_plural": "{{count}} new issues", + "reviewFailed": "Review Failed", + "description": "Description", + "noDescription": "No description provided." }, "downloads": { "toggleExpand": "Toggle download details", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index c8fd0af8..a6e45f3f 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -97,7 +97,59 @@ "commented": "Commenté", "readyForFollowup": "Prêt pour suivi", "readyToMerge": "Prêt à fusionner", - "pendingPost": "En attente de publication" + "pendingPost": "En attente de publication", + "posted": "Publié", + "notReviewed": "Non révisé", + "allStatuses": "Tous les statuts", + "allContributors": "Tous les contributeurs", + "searchPlaceholder": "Rechercher des PRs...", + "contributors": "Contributeurs", + "contributorsSelected": "Contributeurs ({{count}})", + "status": "Statut", + "filters": "Filtres", + "clearFilters": "Effacer", + "clearSearch": "Effacer la recherche", + "searchContributors": "Rechercher des contributeurs...", + "selectedCount": "{{count}} sélectionné(s)", + "noResultsFound": "Aucun résultat trouvé", + "reset": "Réinitialiser", + "pullRequests": "Pull Requests", + "open": "ouvert", + "selectPRToView": "Sélectionnez une pull request pour voir les détails", + "loadingPRs": "Chargement des pull requests...", + "noOpenPRs": "Aucune pull request ouverte", + "notConnected": "GitHub non connecté", + "connectPrompt": "Connectez votre compte GitHub pour voir et réviser les pull requests.", + "openSettings": "Ouvrir les paramètres", + "runAIReview": "Lancer la révision IA", + "reviewStarted": "Révision commencée", + "analysisInProgress": "Analyse IA en cours...", + "analysisComplete": "Analyse terminée ({{count}} résultats)", + "findingsPostedToGitHub": "Résultats publiés sur GitHub", + "newCommits": "{{count}} nouveaux commits", + "runFollowup": "Lancer le suivi", + "aiReviewInProgress": "Révision IA en cours", + "waitingForChanges": "En attente de modifications", + "reviewComplete": "Révision terminée", + "reviewStatus": "Statut de révision", + "files": "fichiers", + "filesChanged": "{{count}} fichiers modifiés", + "posting": "Publication...", + "postFindings": "Publier {{count}} résultat", + "postFindings_plural": "Publier {{count}} résultats", + "approve": "Approuver", + "merge": "Fusionner", + "postedFindings": "{{count}} résultat publié", + "postedFindings_plural": "{{count}} résultats publiés", + "resolved": "{{count}} résolu", + "resolved_plural": "{{count}} résolus", + "stillOpen": "{{count}} encore ouvert", + "stillOpen_plural": "{{count}} encore ouverts", + "newIssue": "{{count}} nouveau problème", + "newIssue_plural": "{{count}} nouveaux problèmes", + "reviewFailed": "Révision échouée", + "description": "Description", + "noDescription": "Aucune description fournie." }, "downloads": { "toggleExpand": "Afficher/masquer les détails",