/ components / OverviewSlide.tsx
OverviewSlide.tsx
1 import { useState } from 'react'; 2 import { 3 ShieldCheck, 4 ChevronDown, 5 ChevronRight, 6 FileText, 7 CheckCircle2, 8 XCircle, 9 Loader2, 10 CircleDot, 11 GitMerge, 12 GitPullRequestDraft, 13 AlertTriangle, 14 Tag, 15 GitBranch, 16 Hash, 17 Users, 18 Zap, 19 Milestone, 20 Globe, 21 } from 'lucide-react'; 22 import { Badge } from '@/components/ui/badge'; 23 import { Card, CardContent } from '@/components/ui/card'; 24 import { Markdown } from '@/components/Markdown'; 25 import { slideTypeConfig, riskConfig } from '@/lib/constants'; 26 import type { PrStatus, ReviewGuide } from '@/lib/types'; 27 28 interface Props { 29 review: ReviewGuide; 30 prStatus: PrStatus | null; 31 onNavigate: (slideNumber: number) => void; 32 } 33 34 function SkeletonPill({ width }: { width: string }) { 35 return <div className="statusPill-skeleton animate-pulse rounded-full" style={{ width, height: 22 }} />; 36 } 37 38 function StatusBarSkeleton() { 39 return ( 40 <div className="max-w-6xl mx-auto w-full mb-4 flex flex-wrap items-center gap-2"> 41 <SkeletonPill width="5.5rem" /> 42 <SkeletonPill width="6rem" /> 43 <SkeletonPill width="6.5rem" /> 44 <SkeletonPill width="5rem" /> 45 <SkeletonPill width="4rem" /> 46 </div> 47 ); 48 } 49 50 function StatusBar({ status }: { status: PrStatus | null }) { 51 if (!status) return <StatusBarSkeleton />; 52 const { 53 ciConclusion, 54 ciChecks, 55 reviewSummary, 56 isDraft, 57 labels, 58 baseBranch, 59 commitCount, 60 requestedReviewers, 61 requestedTeams, 62 mergeableState, 63 autoMerge, 64 milestone, 65 } = status; 66 67 const failCount = ciChecks.filter( 68 (c) => c.conclusion === 'failure' || c.conclusion === 'timed_out' || c.conclusion === 'cancelled' 69 ).length; 70 71 return ( 72 <div className="animate-fade-in-up max-w-6xl mx-auto w-full mb-4 flex flex-wrap items-center gap-2"> 73 {/* Draft indicator */} 74 {isDraft && ( 75 <span className="statusPill-neutral flex items-center gap-1.5"> 76 <GitPullRequestDraft className="h-3 w-3" /> 77 Draft 78 </span> 79 )} 80 81 {/* CI status */} 82 {ciConclusion === 'success' && ( 83 <span className="statusPill-green flex items-center gap-1.5"> 84 <CheckCircle2 className="h-3 w-3" /> 85 CI passing 86 </span> 87 )} 88 {ciConclusion === 'failure' && ( 89 <span className="statusPill-red flex items-center gap-1.5"> 90 <XCircle className="h-3 w-3" /> 91 CI failing{failCount > 0 ? ` (${failCount})` : ''} 92 </span> 93 )} 94 {ciConclusion === 'pending' && ( 95 <span className="statusPill-amber flex items-center gap-1.5"> 96 <Loader2 className="h-3 w-3 animate-spin" /> 97 CI pending 98 </span> 99 )} 100 {ciConclusion === 'neutral' && ciChecks.length === 0 && ( 101 <span className="statusPill-neutral flex items-center gap-1.5"> 102 <CircleDot className="h-3 w-3" /> 103 No CI checks 104 </span> 105 )} 106 107 {/* Review status */} 108 {(reviewSummary.approved > 0 || reviewSummary.changesRequested > 0) && ( 109 <> 110 {reviewSummary.approved > 0 && ( 111 <span className="statusPill-green flex items-center gap-1.5"> 112 <CheckCircle2 className="h-3 w-3" /> 113 {reviewSummary.approved} approved 114 </span> 115 )} 116 {reviewSummary.changesRequested > 0 && ( 117 <span className="statusPill-red flex items-center gap-1.5"> 118 <AlertTriangle className="h-3 w-3" /> 119 {reviewSummary.changesRequested} changes requested 120 </span> 121 )} 122 </> 123 )} 124 {reviewSummary.approved === 0 && reviewSummary.changesRequested === 0 && ( 125 <span className="statusPill-neutral flex items-center gap-1.5"> 126 <CircleDot className="h-3 w-3" /> 127 No reviews 128 </span> 129 )} 130 131 {/* Commit count */} 132 <span className="statusPill-neutral flex items-center gap-1.5"> 133 <Hash className="h-3 w-3" /> 134 {commitCount} {commitCount === 1 ? 'commit' : 'commits'} 135 </span> 136 137 {/* Requested reviewers */} 138 {requestedReviewers.length + requestedTeams.length > 0 && ( 139 <span className="statusPill-amber flex items-center gap-1.5"> 140 <Users className="h-3 w-3" /> 141 Awaiting: {[...requestedReviewers, ...requestedTeams].join(', ')} 142 </span> 143 )} 144 145 {/* Mergeable state */} 146 {mergeableState === 'clean' && ( 147 <span className="statusPill-green flex items-center gap-1.5"> 148 <GitMerge className="h-3 w-3" /> 149 Merge-ready 150 </span> 151 )} 152 {mergeableState === 'behind' && ( 153 <span className="statusPill-amber flex items-center gap-1.5"> 154 <GitMerge className="h-3 w-3" /> 155 Behind base 156 </span> 157 )} 158 {mergeableState === 'dirty' && ( 159 <span className="statusPill-red flex items-center gap-1.5"> 160 <GitMerge className="h-3 w-3" /> 161 Has conflicts 162 </span> 163 )} 164 {mergeableState === 'blocked' && ( 165 <span className="statusPill-red flex items-center gap-1.5"> 166 <GitMerge className="h-3 w-3" /> 167 Merge blocked 168 </span> 169 )} 170 {mergeableState === 'unstable' && ( 171 <span className="statusPill-amber flex items-center gap-1.5"> 172 <GitMerge className="h-3 w-3" /> 173 Unstable 174 </span> 175 )} 176 177 {/* Auto-merge */} 178 {autoMerge && ( 179 <span className="statusPill-green flex items-center gap-1.5"> 180 <Zap className="h-3 w-3" /> 181 Auto-merge ({autoMerge.method}) 182 </span> 183 )} 184 185 {/* Milestone */} 186 {milestone && ( 187 <span 188 className="statusPill-neutral flex items-center gap-1.5" 189 title={milestone.dueOn ? `Due: ${new Date(milestone.dueOn).toLocaleDateString()}` : undefined} 190 > 191 <Milestone className="h-3 w-3" /> 192 {milestone.title} 193 </span> 194 )} 195 196 {/* Labels */} 197 {labels.length > 0 && ( 198 <> 199 {labels.map((label) => ( 200 <span key={label} className="statusPill-label flex items-center gap-1.5"> 201 <Tag className="h-3 w-3" /> 202 {label} 203 </span> 204 ))} 205 </> 206 )} 207 208 {/* Base branch */} 209 <span className="statusPill-neutral flex items-center gap-1.5"> 210 <GitBranch className="h-3 w-3" /> 211 {baseBranch} 212 </span> 213 </div> 214 ); 215 } 216 217 export function OverviewSlide({ review, prStatus, onNavigate }: Props) { 218 const risk = riskConfig[review.riskLevel]; 219 const [descOpen, setDescOpen] = useState(false); 220 const [sourcesOpen, setSourcesOpen] = useState(false); 221 222 return ( 223 <div className="flex-1 overflow-y-auto p-8"> 224 <StatusBar status={prStatus} /> 225 <div className="max-w-6xl mx-auto w-full grid grid-cols-[2fr_3fr] gap-6 items-start"> 226 {/* Left column — context */} 227 <div className="flex flex-col gap-5 sticky top-0"> 228 {/* Summary */} 229 <section className="animate-fade-in-up flex flex-col gap-2"> 230 <p className="text-xs uppercase tracking-wider text-muted-foreground">Summary</p> 231 <Markdown className="text-[15px] leading-7">{review.summary}</Markdown> 232 {(review.neighborFileCount ?? 0) > 0 && ( 233 <p className="text-xs text-muted-foreground"> 234 {review.neighborFileCount} additional {review.neighborFileCount === 1 ? 'file' : 'files'} included for 235 context 236 </p> 237 )} 238 </section> 239 240 {/* Risk — inline */} 241 <section className="animate-fade-in-up flex flex-col gap-2" style={{ animationDelay: '60ms' }}> 242 <p className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1.5"> 243 <ShieldCheck className="h-3 w-3" /> 244 Risk Assessment 245 </p> 246 <div className="rounded-md border bg-muted/20 px-4 py-3 flex gap-3 items-start"> 247 <Badge variant={risk.variant} className="shrink-0 mt-0.5"> 248 {risk.label} 249 </Badge> 250 <Markdown className="text-sm text-muted-foreground leading-relaxed">{review.riskRationale}</Markdown> 251 </div> 252 </section> 253 254 {/* PR Description — collapsible */} 255 {review.prDescription && ( 256 <section className="animate-fade-in-up flex flex-col gap-2" style={{ animationDelay: '120ms' }}> 257 <button 258 onClick={() => setDescOpen((v) => !v)} 259 className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1.5 hover:text-foreground transition-colors" 260 > 261 <FileText className="h-3 w-3" /> 262 PR Description 263 {descOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} 264 </button> 265 {descOpen && ( 266 <div className="text-sm text-muted-foreground leading-relaxed max-h-48 overflow-y-auto rounded-md border bg-muted/20 px-4 py-3"> 267 <Markdown>{review.prDescription}</Markdown> 268 </div> 269 )} 270 </section> 271 )} 272 273 {/* Web Sources — collapsible */} 274 {review.webSources && review.webSources.length > 0 && ( 275 <section className="animate-fade-in-up flex flex-col gap-2" style={{ animationDelay: '180ms' }}> 276 <button 277 onClick={() => setSourcesOpen((v) => !v)} 278 className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1.5 hover:text-foreground transition-colors" 279 > 280 <Globe className="h-3 w-3" /> 281 Web Sources ({review.webSources.length}) 282 {sourcesOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} 283 </button> 284 {sourcesOpen && ( 285 <ul className="flex flex-col gap-1.5 rounded-md border bg-muted/20 px-4 py-3"> 286 {review.webSources.map((source, i) => ( 287 <li key={i}> 288 <button 289 onClick={() => window.electronAPI.openExternal(source.url)} 290 className="text-sm text-primary hover:underline truncate max-w-full text-left" 291 title={source.url} 292 > 293 {source.title || source.url} 294 </button> 295 </li> 296 ))} 297 </ul> 298 )} 299 </section> 300 )} 301 </div> 302 303 {/* Right column — slides navigation */} 304 <section className="flex flex-col gap-3"> 305 <p 306 className="animate-fade-in-up text-xs uppercase tracking-wider text-muted-foreground" 307 style={{ animationDelay: '80ms' }} 308 > 309 Slides 310 </p> 311 <Card> 312 <CardContent className="p-0"> 313 <ol className="divide-y"> 314 {review.slides.map((slide, index) => { 315 const typeConfig = slideTypeConfig[slide.slideType]; 316 const Icon = typeConfig.icon; 317 return ( 318 <li key={slide.id}> 319 <button 320 onClick={() => onNavigate(slide.slideNumber)} 321 className="animate-fade-in-up w-full flex items-center gap-4 px-4 py-3.5 text-left hover:bg-muted/50 transition-colors" 322 style={{ animationDelay: `${120 + index * 50}ms` }} 323 > 324 <span className="text-sm text-muted-foreground w-6 shrink-0 text-right"> 325 {slide.slideNumber} 326 </span> 327 <Badge variant="outline" className={`shrink-0 text-xs gap-1 ${typeConfig.className}`}> 328 <Icon className="h-3 w-3" /> 329 {typeConfig.label} 330 </Badge> 331 <span className="flex-1 text-sm font-medium truncate">{slide.title}</span> 332 <span className="text-xs text-muted-foreground shrink-0"> 333 {slide.affectedFiles.length} {slide.affectedFiles.length === 1 ? 'file' : 'files'} 334 </span> 335 </button> 336 </li> 337 ); 338 })} 339 </ol> 340 </CardContent> 341 </Card> 342 </section> 343 </div> 344 </div> 345 ); 346 }