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) {
- {/* 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' && (
-
+ )}
{/* 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);
+ }
+ }}>
+
+ 0 && "border-solid bg-accent/50"
+ )}
+ >
+
+ {title}
+ {selected.length > 0 && (
+ <>
+
+
+ {selected.length}
+
+
+ {selected.length > 2 ? (
+
+ {selectedCountLabel}
+
+ ) : (
+ renderTrigger ? renderTrigger(selected) : (
+ selected.map((item) => (
+
+ {item}
+
+ ))
+ )
+ )}
+
+ >
+ )}
+
+
+
+
+
+ {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 && (
+
+ onChange([])}
+ >
+ {clearLabel}
+
+
+ )}
+
+
+ );
+}
+
+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 && (
+ onSearchChange('')}
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ aria-label={t('prReview.clearSearch')}
+ >
+
+
+ )}
+
+
+
+
+ {/* 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 && (
+
+ {t('prReview.reset')}
+
+
+ )}
+
+
+ );
+}
+
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",