feat: enhance pr review page to include PRs filters (#423)

* fix: Allow windows to run CC PR Reviewer

Signed-off-by: Alex Madera <e.a_madera@hotmail.com>

* solve auto claude comments

* fix linting

* feat: enhance github PR page to include filters

* solve pr comments

* feat(i18n): add translation keys for PR review status tree

Replace hardcoded strings in ReviewStatusTree and PRHeader components
with i18n translation keys for proper internationalization:
- Add useTranslation hook to ReviewStatusTree and PRHeader components
- Replace all status labels, button text, and dynamic descriptions
- Add interpolation for count-based strings (findings, commits, files)
- Add 13 new translation keys to en/common.json and fr/common.json

New translation keys added:
- prReview.runAIReview, reviewStarted, analysisInProgress
- prReview.analysisComplete (with count interpolation)
- prReview.findingsPostedToGitHub, newCommits, runFollowup
- prReview.aiReviewInProgress, waitingForChanges, reviewComplete
- prReview.reviewStatus, files, filesChanged

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(i18n): add translation keys for PR action bar

Replace hardcoded strings in PRDetail Action Bar with i18n keys:
- Add useTranslation hook to PRDetail component
- Replace "Posting...", "Post X Finding(s)", "Approve", "Merge",
  and "Posted X finding(s)" with translation keys
- Use i18next pluralization (_plural suffix) for count-based strings
- Add 7 new translation keys to en/common.json and fr/common.json

New translation keys with pluralization support:
- prReview.posting - loading state
- prReview.postFindings / postFindings_plural - post button
- prReview.approve - approve button
- prReview.merge - merge button
- prReview.postedFindings / postedFindings_plural - success message

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(i18n): add translation keys for follow-up review and description

Replace hardcoded strings with i18n translation keys:

Follow-up review badges (lines 693-714):
- "X resolved" → t('prReview.resolved', { count })
- "X still open" → t('prReview.stillOpen', { count })
- "X new issue(s)" → t('prReview.newIssue', { count }) with pluralization

Description section (lines 746-762):
- "Description" → t('prReview.description')
- "No description provided." → t('prReview.noDescription')
- "Review Failed" → t('prReview.reviewFailed')

Added 9 new translation keys with plural forms to both locale files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Signed-off-by: Alex Madera <e.a_madera@hotmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alex
2025-12-30 13:59:58 +01:00
committed by GitHub
parent 0a571d3a2b
commit 717fba0440
13 changed files with 1306 additions and 310 deletions
@@ -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 {
@@ -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;
}
/**
@@ -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 (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<GitPullRequest className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-medium mb-2">GitHub Not Connected</h3>
<h3 className="text-lg font-medium mb-2">{t('prReview.notConnected')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{error || 'Connect your GitHub account to view and review pull requests.'}
{error || t('prReview.connectPrompt')}
</p>
{onOpenSettings && (
<Button onClick={onOpenSettings} variant="outline">
<Settings className="h-4 w-4 mr-2" />
Open Settings
{t('prReview.openSettings')}
</Button>
)}
</div>
@@ -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 <NotConnectedState error={error} onOpenSettings={onOpenSettings} />;
return <NotConnectedState error={error} onOpenSettings={onOpenSettings} t={t} />;
}
return (
@@ -139,7 +156,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
<div className="flex items-center gap-3">
<h2 className="text-sm font-medium flex items-center gap-2">
<GitPullRequest className="h-4 w-4" />
Pull Requests
{t('prReview.pullRequests')}
</h2>
{repoFullName && (
<a
@@ -153,7 +170,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
</a>
)}
<span className="text-xs text-muted-foreground">
{prs.length} open
{prs.length} {t('prReview.open')}
</span>
</div>
<Button
@@ -166,24 +183,35 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
</Button>
</div>
{/* Content */}
<div className="flex-1 flex min-h-0">
{/* PR List */}
<div className="w-1/2 border-r border-border flex flex-col">
<PRList
prs={prs}
selectedPRNumber={selectedPRNumber}
isLoading={isLoading}
error={error}
activePRReviews={activePRReviews}
getReviewStateForPR={getReviewStateForPR}
onSelectPR={selectPR}
/>
</div>
{/* PR Detail */}
<div className="w-1/2 flex flex-col">
{selectedPR ? (
{/* Content - Resizable split panels */}
<ResizablePanels
defaultLeftWidth={50}
minLeftWidth={30}
maxLeftWidth={70}
storageKey="github-prs-panel-width"
leftPanel={
<div className="flex flex-col h-full">
<PRFilterBar
filters={filters}
contributors={contributors}
hasActiveFilters={hasActiveFilters}
onSearchChange={setSearchQuery}
onContributorsChange={setContributors}
onStatusesChange={setStatuses}
onClearFilters={clearFilters}
/>
<PRList
prs={filteredPRs}
selectedPRNumber={selectedPRNumber}
isLoading={isLoading}
error={error}
getReviewStateForPR={getReviewStateForPR}
onSelectPR={selectPR}
/>
</div>
}
rightPanel={
selectedPR ? (
<PRDetail
pr={selectedPR}
reviewResult={reviewResult}
@@ -199,10 +227,10 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
onAssignPR={handleAssignPR}
/>
) : (
<EmptyState message="Select a pull request to view details" />
)}
</div>
</div>
<EmptyState message={t('prReview.selectPRToView')} />
)
}
/>
</div>
);
}
@@ -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 (
<div className="flex flex-wrap items-center justify-between gap-y-3 p-4 border rounded-lg bg-card shadow-sm">
<div className="flex items-center gap-3 min-w-0">
<div className="h-2.5 w-2.5 shrink-0 rounded-full bg-muted-foreground/30" />
<span className="font-medium text-muted-foreground truncate">{t('prReview.notReviewed')}</span>
</div>
<Button onClick={onRunReview} size="sm" className="gap-2 shrink-0 ml-auto sm:ml-0">
<Play className="h-3.5 w-3.5" />
{t('prReview.runAIReview')}
</Button>
</div>
);
}
// 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: (
<Button size="sm" variant="outline" onClick={onRunFollowupReview} className="ml-2 h-6 text-xs px-2">
{t('prReview.runFollowup')}
</Button>
)
});
}
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="border rounded-lg bg-card shadow-sm overflow-hidden"
>
{/* Header / Status Bar */}
<div className="p-4 flex flex-wrap items-center justify-between gap-y-2 bg-muted/30">
<div className="flex items-center gap-3 min-w-0 pr-2">
<div className={cn("h-2.5 w-2.5 shrink-0 rounded-full",
isReviewing ? "bg-blue-500 animate-pulse" :
status === 'ready_to_merge' ? "bg-success" :
status === 'waiting_for_changes' ? "bg-warning" :
status === 'reviewed_pending_post' ? "bg-primary" :
status === 'ready_for_followup' ? "bg-info" :
"bg-muted-foreground"
)} />
<span className="font-medium truncate">
{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')}
</span>
</div>
<div className="flex items-center gap-2 shrink-0 ml-auto sm:ml-0">
{isReviewing && (
<Button variant="ghost" size="sm" onClick={onCancelReview} className="h-7 text-destructive hover:text-destructive">
{t('buttons.cancel')}
</Button>
)}
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
</div>
</div>
{/* Collapsible Tree */}
<CollapsibleContent>
<div className="p-4 pt-0">
<div className="relative pl-2 ml-2 border-l border-border/50 space-y-4 pt-4">
{steps.map((step) => (
<div key={step.id} className="relative flex items-start gap-3 pl-4">
{/* Node Dot */}
<div className={cn("absolute -left-[13px] top-1 bg-background rounded-full p-0.5 border",
step.status === 'completed' ? "border-success text-success" :
step.status === 'current' ? "border-primary text-primary animate-pulse" :
step.status === 'alert' ? "border-warning text-warning" :
"border-muted-foreground text-muted-foreground"
)}>
{step.status === 'completed' ? <CheckCircle className="h-3 w-3" /> :
step.status === 'current' ? <CircleDot className="h-3 w-3" /> :
<Circle className="h-3 w-3" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className={cn("text-sm font-medium truncate max-w-full",
step.status === 'completed' ? "text-foreground" :
step.status === 'current' ? "text-primary" :
"text-muted-foreground"
)}>
{step.label}
</span>
{step.action}
</div>
{step.date && (
<div className="text-xs text-muted-foreground mt-0.5">
{formatDate(step.date)}
</div>
)}
</div>
</div>
))}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
}
// Modern Header Component
function PRHeader({ pr }: { pr: PRData }) {
const { t } = useTranslation('common');
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Badge variant={pr.state.toLowerCase() === 'open' ? 'success' : 'secondary'} className={cn(
"capitalize px-2.5 py-0.5",
pr.state.toLowerCase() === 'open' ? "bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20" : ""
)}>
{pr.state}
</Badge>
<span className="text-muted-foreground text-sm font-mono">#{pr.number}</span>
</div>
<Button variant="ghost" size="icon" asChild className="h-8 w-8 text-muted-foreground hover:text-foreground">
<a href={pr.htmlUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
<h1 className="text-xl font-bold mb-4 leading-tight">{pr.title}</h1>
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm text-muted-foreground border-b border-border/40 pb-5">
<div className="flex items-center gap-2">
<div className="bg-muted rounded-full p-1">
<User className="h-3.5 w-3.5" />
</div>
<span className="font-medium text-foreground">{pr.author.login}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 opacity-70" />
<span>{formatDate(pr.createdAt)}</span>
</div>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 font-mono text-xs border border-border/50">
<GitBranch className="h-3 w-3" />
<span className="text-foreground">{pr.headRefName}</span>
<span className="text-muted-foreground/50 mx-1"></span>
<span className="text-foreground">{pr.baseRefName}</span>
</div>
<div className="flex items-center gap-4 ml-auto">
<div className="flex items-center gap-1.5" title={t('prReview.filesChanged', { count: pr.changedFiles })}>
<FileDiff className="h-4 w-4" />
<span className="font-medium text-foreground">{pr.changedFiles}</span>
<span className="text-xs">{t('prReview.files')}</span>
</div>
<div className="flex items-center gap-2 text-xs font-mono">
<span className="text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded">+{pr.additions}</span>
<span className="text-red-500 bg-red-500/10 px-1.5 py-0.5 rounded">-{pr.deletions}</span>
</div>
</div>
</div>
</div>
);
}
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<Set<string>>(new Set());
const [postedFindingIds, setPostedFindingIds] = useState<Set<string>>(new Set());
@@ -330,230 +588,98 @@ export function PRDetail({
return (
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-success/20 text-success border-success/50">
Open
</Badge>
<span className="text-sm text-muted-foreground">#{pr.number}</span>
</div>
<Button variant="ghost" size="icon" asChild>
<a href={pr.htmlUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
<h2 className="text-lg font-semibold text-foreground">{pr.title}</h2>
</div>
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Refactored Header */}
<PRHeader pr={pr} />
{/* Meta */}
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
{pr.author.login}
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{formatDate(pr.createdAt)}
</div>
<div className="flex items-center gap-1">
<GitBranch className="h-4 w-4" />
{pr.headRefName} {pr.baseRefName}
</div>
{pr.assignees && pr.assignees.length > 0 && (
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
{pr.assignees.map(a => a.login).join(', ')}
</div>
)}
</div>
{/* Review Status & Actions */}
<ReviewStatusTree
status={prStatus.status}
isReviewing={isReviewing}
reviewResult={reviewResult}
postedCount={postedFindingIds.size + (reviewResult?.postedFindingIds?.length ?? 0)}
onRunReview={onRunReview}
onRunFollowupReview={onRunFollowupReview}
onCancelReview={onCancelReview}
newCommitsCheck={newCommitsCheck}
lastPostedAt={postSuccess?.timestamp}
/>
{/* Stats */}
<div className="flex items-center gap-4">
<Badge variant="outline" className="flex items-center gap-1">
<FileDiff className="h-3 w-3" />
{pr.changedFiles} files
</Badge>
<span className="text-sm text-success">+{pr.additions}</span>
<span className="text-sm text-destructive">-{pr.deletions}</span>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
{/* Show Follow-up Review button if there are new commits since last review */}
{newCommitsCheck?.hasNewCommits && !isReviewing ? (
<Button
onClick={onRunFollowupReview}
disabled={isReviewing}
className="flex-1"
variant="secondary"
>
<RefreshCw className="h-4 w-4 mr-2" />
Follow-up Review ({newCommitsCheck.newCommitCount} new commit{newCommitsCheck.newCommitCount !== 1 ? 's' : ''})
</Button>
) : (
<Button
onClick={onRunReview}
disabled={isReviewing}
className="flex-1"
>
{isReviewing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Reviewing...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
Run AI Review
</>
)}
</Button>
)}
{isReviewing && (
<Button onClick={onCancelReview} variant="destructive">
<XCircle className="h-4 w-4 mr-2" />
Cancel
</Button>
)}
{reviewResult && reviewResult.success && selectedCount > 0 && !isReviewing && (
<Button onClick={handlePostReview} variant="secondary" disabled={isPostingFindings}>
{isPostingFindings ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Posting...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Post {selectedCount} Finding{selectedCount !== 1 ? 's' : ''}
</>
)}
</Button>
)}
{/* Success message */}
{postSuccess && (
<div className="flex items-center gap-2 text-success text-sm">
<CheckCircle className="h-4 w-4" />
Posted {postSuccess.count} finding{postSuccess.count !== 1 ? 's' : ''} to GitHub
</div>
)}
</div>
{/* Approval and Merge buttons */}
{reviewResult && reviewResult.success && isReadyToMerge && (
<div className="flex items-center gap-2">
<Button
onClick={handleApprove}
disabled={isPosting}
variant="default"
className="flex-1 bg-success hover:bg-success/90"
>
{isPosting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Posting...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
Approve
</>
)}
</Button>
<Button
onClick={handleMerge}
disabled={isMerging}
variant="outline"
className="flex-1"
>
{isMerging ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Merging...
</>
) : (
<>
<GitMerge className="h-4 w-4 mr-2" />
Merge PR
</>
)}
</Button>
</div>
)}
</div>
{/* PR Review Status Banner */}
<Card className={`border-2 ${prStatus.color} ${prStatus.status === 'ready_for_followup' ? 'animate-pulse-subtle' : ''}`}>
<CardContent className="py-3">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${prStatus.color}`}>
{prStatus.icon}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium">{prStatus.label}</div>
<div className="text-sm text-muted-foreground truncate">{prStatus.description}</div>
</div>
{prStatus.status === 'ready_for_followup' && (
<Button
onClick={onRunFollowupReview}
disabled={isReviewing}
className="bg-info hover:bg-info/90 text-info-foreground shrink-0"
>
{isReviewing ? (
{/* Action Bar (Legacy Actions that fit under the tree context) */}
{reviewResult && reviewResult.success && !isReviewing && (
<div className="flex flex-wrap items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
{selectedCount > 0 && (
<Button onClick={handlePostReview} variant="secondary" disabled={isPostingFindings} className="flex-1 sm:flex-none">
{isPostingFindings ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Reviewing...
{t('prReview.posting')}
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Run Follow-up Review
<Send className="h-4 w-4 mr-2" />
{t('prReview.postFindings', { count: selectedCount })}
</>
)}
</Button>
)}
{prStatus.status === 'waiting_for_changes' && newCommitsCheck?.hasNewCommits && (
<Badge variant="outline" className="bg-primary/20 text-primary border-primary/50 shrink-0">
<RefreshCw className="h-3 w-3 mr-1" />
{newCommitsCheck.newCommitCount} new commit{newCommitsCheck.newCommitCount !== 1 ? 's' : ''}
</Badge>
)}
</div>
</CardContent>
</Card>
)}
{isReadyToMerge && (
<>
<Button
onClick={handleApprove}
disabled={isPosting}
variant="default"
className="flex-1 sm:flex-none bg-emerald-600 hover:bg-emerald-700 text-white"
>
{isPosting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <CheckCircle className="h-4 w-4 mr-2" />}
{t('prReview.approve')}
</Button>
<Button
onClick={handleMerge}
disabled={isMerging}
variant="outline"
className="flex-1 sm:flex-none"
>
{isMerging ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <GitMerge className="h-4 w-4 mr-2" />}
{t('prReview.merge')}
</Button>
</>
)}
{postSuccess && (
<div className="ml-auto flex items-center gap-2 text-emerald-600 text-sm font-medium animate-pulse">
<CheckCircle className="h-4 w-4" />
{t('prReview.postedFindings', { count: postSuccess.count })}
</div>
)}
</div>
)}
{/* Review Progress */}
{reviewProgress && (
<Card>
<CardContent className="pt-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{reviewProgress.message}</span>
<span className="text-muted-foreground">{reviewProgress.progress}%</span>
</div>
<Progress value={reviewProgress.progress} />
</div>
</CardContent>
</Card>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{reviewProgress.message}</span>
<span className="text-muted-foreground">{reviewProgress.progress}%</span>
</div>
<Progress value={reviewProgress.progress} className="h-2" />
</div>
)}
{/* Review Result */}
{/* Review Result / Findings */}
{reviewResult && reviewResult.success && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center justify-between">
<Card className="border shadow-sm">
<CardHeader className="pb-3 border-b bg-muted/20">
<CardTitle className="text-sm font-medium flex items-center justify-between">
<span className="flex items-center gap-2">
{reviewResult.isFollowupReview ? (
<RefreshCw className="h-4 w-4" />
<RefreshCw className="h-4 w-4 text-blue-500" />
) : (
<Sparkles className="h-4 w-4" />
<Sparkles className="h-4 w-4 text-purple-500" />
)}
{reviewResult.isFollowupReview ? 'Follow-up Review' : 'AI Review Result'}
{reviewResult.isFollowupReview ? 'Follow-up Review Details' : 'AI Analysis Results'}
</span>
<Badge variant="outline" className={getStatusColor(reviewResult.overallStatus)}>
{reviewResult.overallStatus === 'approve' && 'Approve'}
@@ -562,32 +688,34 @@ export function PRDetail({
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 overflow-hidden">
<CardContent className="space-y-6 pt-6">
{/* Follow-up Review Resolution Status */}
{reviewResult.isFollowupReview && (
<div className="flex flex-wrap gap-2 pb-2 border-b border-border">
<div className="flex flex-wrap gap-3 pb-4 border-b border-border/50">
{(reviewResult.resolvedFindings?.length ?? 0) > 0 && (
<Badge variant="outline" className="bg-success/20 text-success border-success/50">
<CheckCircle className="h-3 w-3 mr-1" />
{reviewResult.resolvedFindings?.length} resolved
<Badge variant="outline" className="bg-success/10 text-success border-success/30 px-3 py-1">
<CheckCircle className="h-3.5 w-3.5 mr-1.5" />
{t('prReview.resolved', { count: reviewResult.resolvedFindings?.length ?? 0 })}
</Badge>
)}
{(reviewResult.unresolvedFindings?.length ?? 0) > 0 && (
<Badge variant="outline" className="bg-warning/20 text-warning border-warning/50">
<AlertCircle className="h-3 w-3 mr-1" />
{reviewResult.unresolvedFindings?.length} still open
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/30 px-3 py-1">
<AlertCircle className="h-3.5 w-3.5 mr-1.5" />
{t('prReview.stillOpen', { count: reviewResult.unresolvedFindings?.length ?? 0 })}
</Badge>
)}
{(reviewResult.newFindingsSinceLastReview?.length ?? 0) > 0 && (
<Badge variant="outline" className="bg-destructive/20 text-destructive border-destructive/50">
<XCircle className="h-3 w-3 mr-1" />
{reviewResult.newFindingsSinceLastReview?.length} new issue{reviewResult.newFindingsSinceLastReview?.length !== 1 ? 's' : ''}
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/30 px-3 py-1">
<XCircle className="h-3.5 w-3.5 mr-1.5" />
{t('prReview.newIssue', { count: reviewResult.newFindingsSinceLastReview?.length ?? 0 })}
</Badge>
)}
</div>
)}
<p className="text-sm text-muted-foreground break-words">{reviewResult.summary}</p>
<div className="bg-muted/30 p-4 rounded-lg text-sm text-muted-foreground leading-relaxed">
{reviewResult.summary}
</div>
{/* Interactive Findings with Selection */}
<ReviewFindings
@@ -596,26 +724,20 @@ export function PRDetail({
postedIds={postedFindingIds}
onSelectionChange={setSelectedFindingIds}
/>
{reviewResult.reviewedAt && (
<p className="text-xs text-muted-foreground">
Reviewed: {formatDate(reviewResult.reviewedAt)}
{reviewResult.reviewedCommitSha && (
<> at commit {reviewResult.reviewedCommitSha.substring(0, 7)}</>
)}
</p>
)}
</CardContent>
</Card>
)}
{/* Review Error */}
{reviewResult && !reviewResult.success && reviewResult.error && (
<Card className="border-destructive">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-destructive">
<XCircle className="h-4 w-4" />
<span className="text-sm">{reviewResult.error}</span>
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<div className="flex items-start gap-3 text-destructive">
<XCircle className="h-5 w-5 mt-0.5" />
<div className="space-y-1">
<p className="font-semibold">{t('prReview.reviewFailed')}</p>
<p className="text-sm opacity-90">{reviewResult.error}</p>
</div>
</div>
</CardContent>
</Card>
@@ -624,47 +746,20 @@ export function PRDetail({
{/* Description */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Description</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t('prReview.description')}</CardTitle>
</CardHeader>
<CardContent className="overflow-hidden">
{pr.body ? (
<pre className="whitespace-pre-wrap text-sm text-muted-foreground font-sans break-words max-w-full overflow-hidden">
{pr.body}
</pre>
) : (
<p className="text-sm text-muted-foreground italic">
No description provided.
</p>
)}
<CardContent>
<ScrollArea className="h-[400px] w-full rounded-md border p-4 bg-muted/10">
{pr.body ? (
<pre className="whitespace-pre-wrap text-sm text-muted-foreground font-sans break-words">
{pr.body}
</pre>
) : (
<p className="text-sm text-muted-foreground italic">{t('prReview.noDescription')}</p>
)}
</ScrollArea>
</CardContent>
</Card>
{/* Changed Files */}
{pr.files && pr.files.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Changed Files ({pr.files.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{pr.files.map((file) => (
<div
key={file.path}
className="flex items-center justify-between text-xs py-1"
>
<code className="text-muted-foreground truncate flex-1">
{file.path}
</code>
<div className="flex items-center gap-2 ml-2">
<span className="text-success">+{file.additions}</span>
<span className="text-destructive">-{file.deletions}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</ScrollArea>
);
@@ -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<T extends string>({
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 (
<DropdownMenu open={isOpen} onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setSearchTerm('');
setFocusedIndex(-1);
}
}}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-8 w-full justify-start border-dashed bg-transparent",
selected.length > 0 && "border-solid bg-accent/50"
)}
>
<Icon className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="truncate">{title}</span>
{selected.length > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selected.length}
</Badge>
<div className="hidden space-x-1 lg:flex flex-1 truncate">
{selected.length > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedCountLabel}
</Badge>
) : (
renderTrigger ? renderTrigger(selected) : (
selected.map((item) => (
<Badge
variant="secondary"
key={item}
className="rounded-sm px-1 font-normal"
>
{item}
</Badge>
))
)
)}
</div>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[240px] p-0">
<div className="px-3 py-2 border-b border-border/50">
<div className="text-xs font-semibold text-muted-foreground mb-1">
{title}
</div>
{searchable && (
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
className="h-7 text-xs pl-7 bg-muted/50 border-none focus-visible:ring-1 focus-visible:ring-primary/50"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
<div
className="max-h-[300px] overflow-y-auto custom-scrollbar p-1"
role="listbox"
aria-multiselectable="true"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{filteredItems.length === 0 ? (
<div className="p-3 text-xs text-muted-foreground text-center">
{noResultsLabel}
</div>
) : (
filteredItems.map((item, index) => {
const isSelected = selected.includes(item);
const isFocused = index === focusedIndex;
return (
<div
key={item}
ref={(el) => { 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}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary/30",
isSelected ? "bg-primary border-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible"
)}>
<Check className={cn("h-3 w-3")} />
</div>
{renderItem ? renderItem(item) : item}
</div>
);
})
)}
</div>
{selected.length > 0 && (
<div className="p-1 border-t border-border/50 bg-muted/20">
<Button
variant="ghost"
size="sm"
className="w-full justify-center text-xs h-7 hover:bg-destructive/10 hover:text-destructive"
onClick={() => onChange([])}
>
{clearLabel}
</Button>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<div className="px-4 py-2 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 h-9">
{/* Search Input - Flexible width */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('prReview.searchPlaceholder')}
value={filters.searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 pl-9 bg-background/50 focus:bg-background transition-colors"
/>
{filters.searchQuery && (
<button
onClick={() => onSearchChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={t('prReview.clearSearch')}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<Separator orientation="vertical" className="h-5 mx-1" />
{/* Contributors Filter */}
<div className="flex-1 max-w-[240px]">
<FilterDropdown
title={t('prReview.contributors')}
icon={Users}
items={contributors}
selected={filters.contributors}
onChange={onContributorsChange}
searchable={true}
searchPlaceholder={t('prReview.searchContributors')}
selectedCountLabel={t('prReview.selectedCount', { count: filters.contributors.length })}
noResultsLabel={t('prReview.noResultsFound')}
clearLabel={t('prReview.clearFilters')}
renderItem={(contributor) => (
<div className="flex items-center gap-2 min-w-0">
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-[10px] font-medium text-primary">
{contributor.slice(0, 2).toUpperCase()}
</span>
</div>
<span className="truncate text-sm">{contributor}</span>
</div>
)}
/>
</div>
{/* Status Filter */}
<div className="flex-1 max-w-[240px]">
<FilterDropdown
title={t('prReview.allStatuses')}
icon={Filter}
items={STATUS_OPTIONS.map((opt) => 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 (
<div className="flex items-center gap-2">
<div className={cn("p-1 rounded-full", option.bgColor)}>
<Icon className={cn("h-3 w-3", option.color)} />
</div>
<span className="text-sm">{t(option.labelKey)}</span>
</div>
);
}}
renderTrigger={(selected) => (
selected.map(status => {
const option = getStatusOption(status);
if (!option) return null;
const Icon = option.icon;
return (
<Badge
variant="secondary"
key={status}
className={cn(
"rounded-sm px-1 font-normal gap-1",
option.bgColor,
option.color
)}
>
<Icon className="h-3 w-3" />
<span className="truncate max-w-[80px]">{t(option.labelKey)}</span>
</Badge>
);
})
)}
/>
</div>
{/* Reset All */}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="h-8 px-2 lg:px-3 text-muted-foreground hover:text-foreground ml-auto"
>
<span className="hidden lg:inline mr-2">{t('prReview.reset')}</span>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
}
@@ -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
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<GitPullRequest className="h-8 w-8 mx-auto mb-2 animate-pulse" />
<p>Loading pull requests...</p>
<p>{t('prReview.loadingPRs')}</p>
</div>
</div>
);
@@ -158,7 +157,7 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReview
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<GitPullRequest className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No open pull requests</p>
<p>{t('prReview.noOpenPRs')}</p>
</div>
</div>
);
@@ -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 && (
<Badge variant="outline" className="text-xs flex items-center gap-1 text-blue-500 border-blue-500/50">
<CheckCircle2 className="h-3 w-3" />
{t('prReview.reviewed')}
</Badge>
)}
{/* Show "Posted" when findings posted to GitHub but no full review ID */}
{!reviewState.result.reviewId && reviewState.result.hasPostedFindings && (
<Badge variant="purple" className="text-xs flex items-center gap-1">
<Send className="h-3 w-3" />
{t('prReview.posted')}
</Badge>
)}
{/* Show actual status only after posted to GitHub (has reviewId) */}
{reviewState.result.reviewId && reviewState.result.overallStatus === 'approve' && (
<Badge variant="outline" className="text-xs flex items-center gap-1 text-success border-success/50">
@@ -1,2 +1,3 @@
export { PRList } from './PRList';
export { PRDetail } from './PRDetail';
export { PRFilterBar } from './PRFilterBar';
@@ -1,4 +1,6 @@
export { useGitHubPRs } from './useGitHubPRs';
export { usePRFiltering } from './usePRFiltering';
export type { PRFilterState, PRStatusFilter } from './usePRFiltering';
export type {
PRData,
PRReviewFinding,
@@ -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;
@@ -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<PRFilterState>(DEFAULT_FILTERS);
// Derive unique contributors from PRs
const contributors = useMemo(() => {
const authorSet = new Set<string>();
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,
};
}
@@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
className={cn("flex-1 flex min-h-0", className)}
>
{/* Left panel */}
<div
className="flex flex-col min-w-0 overflow-hidden"
style={{ width: `${leftWidth}%` }}
>
{leftPanel}
</div>
{/* Resizable divider */}
<div
className={cn(
"w-1 flex-shrink-0 relative cursor-col-resize touch-none",
"bg-border transition-colors duration-150",
"hover:bg-primary/40",
isDragging && "bg-primary/60"
)}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
{/* Wider invisible hit area for easier grabbing */}
<div className="absolute inset-y-0 -left-1 -right-1 z-10" />
</div>
{/* Right panel */}
<div
className="flex flex-col min-w-0 overflow-hidden"
style={{ width: `${100 - leftWidth}%` }}
>
{rightPanel}
</div>
</div>
);
}
@@ -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",
@@ -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",