/ components / OverviewSlide.tsx
OverviewSlide.tsx
1 import { useState } from 'react'; 2 import { ChevronDown, ChevronRight } from 'lucide-react'; 3 import { Markdown } from '@/components/Markdown'; 4 import { riskConfig, safeConfigLookup } from '@/lib/constants'; 5 import type { PrStatus, ReviewGuide } from '@/lib/types'; 6 7 interface Props { 8 review: ReviewGuide; 9 prStatus: PrStatus | null; 10 onNavigate: (slideNumber: number) => void; 11 } 12 13 // ─── StatusLine ──────────────────────────────────────────────── 14 // 15 // Quiet mono line summarizing the PR's GitHub-side state. Replaces 16 // the previous cluster of 7+ colored pills. Each segment is plain 17 // text in muted-foreground; only segments that signal a *problem* 18 // (CI failing, conflicts, blocked, changes requested) get the warm 19 // coral color, so red actually means red. Everything else is just 20 // text — color as punctuation, not category shorthand. 21 function StatusLine({ status }: { status: PrStatus | null }) { 22 if (!status) { 23 return ( 24 <div className="slide-meta animate-pulse opacity-60 max-w-6xl mx-auto w-full mb-6"> 25 loading PR status… 26 </div> 27 ); 28 } 29 30 const { 31 ciConclusion, 32 ciChecks, 33 reviewSummary, 34 isDraft, 35 labels, 36 baseBranch, 37 commitCount, 38 requestedReviewers, 39 requestedTeams, 40 mergeableState, 41 autoMerge, 42 milestone, 43 } = status; 44 45 const failCount = ciChecks.filter( 46 (c) => c.conclusion === 'failure' || c.conclusion === 'timed_out' || c.conclusion === 'cancelled' 47 ).length; 48 49 type Segment = { text: string; tone?: 'warn' | 'error' }; 50 const segments: Segment[] = []; 51 52 if (isDraft) segments.push({ text: 'draft' }); 53 54 if (ciConclusion === 'success') segments.push({ text: 'CI passing' }); 55 else if (ciConclusion === 'failure') 56 segments.push({ text: failCount > 0 ? `CI failing (${failCount})` : 'CI failing', tone: 'error' }); 57 else if (ciConclusion === 'pending') segments.push({ text: 'CI pending', tone: 'warn' }); 58 else if (ciChecks.length === 0) segments.push({ text: 'no CI' }); 59 60 if (reviewSummary.approved > 0) { 61 segments.push({ text: `${reviewSummary.approved} approved` }); 62 } 63 if (reviewSummary.changesRequested > 0) { 64 segments.push({ text: `${reviewSummary.changesRequested} changes requested`, tone: 'error' }); 65 } 66 if (reviewSummary.approved === 0 && reviewSummary.changesRequested === 0) { 67 segments.push({ text: 'no reviews' }); 68 } 69 70 segments.push({ text: `${commitCount} ${commitCount === 1 ? 'commit' : 'commits'}` }); 71 72 const awaiting = [...requestedReviewers, ...requestedTeams]; 73 if (awaiting.length > 0) { 74 segments.push({ text: `awaiting ${awaiting.join(', ')}`, tone: 'warn' }); 75 } 76 77 // Mergeable state — only flag the non-clean states; "clean" is 78 // the default and doesn't deserve a segment of its own. 79 if (mergeableState === 'behind') segments.push({ text: 'behind base', tone: 'warn' }); 80 else if (mergeableState === 'dirty') segments.push({ text: 'has conflicts', tone: 'error' }); 81 else if (mergeableState === 'blocked') segments.push({ text: 'merge blocked', tone: 'error' }); 82 else if (mergeableState === 'unstable') segments.push({ text: 'unstable', tone: 'warn' }); 83 84 if (autoMerge) segments.push({ text: `auto-merge (${autoMerge.method})` }); 85 86 if (milestone) segments.push({ text: `milestone: ${milestone.title}` }); 87 88 // Cap labels at 3 to avoid the line ballooning on label-heavy PRs. 89 const visibleLabels = labels.slice(0, 3); 90 if (visibleLabels.length > 0) { 91 segments.push({ text: visibleLabels.join(', ') }); 92 if (labels.length > visibleLabels.length) { 93 segments.push({ text: `+${labels.length - visibleLabels.length} more` }); 94 } 95 } 96 97 segments.push({ text: `→ ${baseBranch}` }); 98 99 const toneClass = (tone?: 'warn' | 'error') => 100 tone === 'error' 101 ? 'text-[var(--color-danger)]' 102 : tone === 'warn' 103 ? 'text-[var(--color-warning)]' 104 : 'text-muted-foreground'; 105 106 return ( 107 <div className="slide-meta animate-fade-in-up max-w-6xl mx-auto w-full mb-8 leading-relaxed"> 108 {segments.map((seg, i) => ( 109 <span key={i}> 110 <span className={toneClass(seg.tone)}>{seg.text}</span> 111 {i < segments.length - 1 && <span className="text-muted-foreground/40 mx-2">·</span>} 112 </span> 113 ))} 114 </div> 115 ); 116 } 117 118 // ─── OverviewSlide ───────────────────────────────────────────── 119 120 export function OverviewSlide({ review, prStatus, onNavigate }: Props) { 121 const risk = safeConfigLookup(riskConfig, review.riskLevel, riskConfig.low); 122 const [descOpen, setDescOpen] = useState(false); 123 const [sourcesOpen, setSourcesOpen] = useState(false); 124 const [remainingOpen, setRemainingOpen] = useState(false); 125 126 // Files that appear in changedFiles but not in any slide's 127 // affectedFiles. These are the "remaining changes" — files in the 128 // PR that the AI didn't feature in the walkthrough. 129 const remainingFiles = (() => { 130 if (!review.changedFiles || review.changedFiles.length === 0) return []; 131 const narrated = new Set(review.slides.flatMap((s) => s.affectedFiles)); 132 return review.changedFiles.filter((f) => !narrated.has(f.filename)); 133 })(); 134 135 const riskToneClass = 136 review.riskLevel === 'high' 137 ? 'text-[var(--color-danger)]' 138 : review.riskLevel === 'medium' 139 ? 'text-[var(--color-warning)]' 140 : 'text-muted-foreground'; 141 142 // The first real chapter — used to render the prominent 143 // "Start reading" call-to-action at the bottom of the prose. 144 // .at() returns T | undefined regardless of noUncheckedIndexedAccess. 145 const firstSlide = review.slides.at(0); 146 147 return ( 148 <div className="flex-1 overflow-y-auto px-10 py-10"> 149 <StatusLine status={prStatus} /> 150 151 {/* Single-column prose layout. The persistent TocRail in the 152 parent ReviewPage replaces the previous right-column TOC, 153 which frees the overview to be a comfortable reading 154 column at editorial measure. */} 155 <div className="max-w-3xl mx-auto w-full flex flex-col gap-8"> 156 {/* Summary — no label, the prose stands on its own. */} 157 <section className="animate-fade-in-up"> 158 <Markdown className="slide-prose">{review.summary}</Markdown> 159 {(review.neighborFileCount ?? 0) > 0 && ( 160 <p className="slide-meta mt-3"> 161 {review.neighborFileCount} additional {review.neighborFileCount === 1 ? 'file' : 'files'} included for 162 context 163 </p> 164 )} 165 </section> 166 167 {/* Risk — a single inline editorial line. */} 168 <section className="animate-fade-in-up" style={{ animationDelay: '60ms' }}> 169 <p className="slide-prose"> 170 <span className={`editorial-label ${riskToneClass}`}>{risk.label}.</span>{' '} 171 <span className="text-muted-foreground">{review.riskRationale}</span> 172 </p> 173 </section> 174 175 {/* PR Description — collapsible inline disclosure. */} 176 {review.prDescription && ( 177 <section className="animate-fade-in-up" style={{ animationDelay: '120ms' }}> 178 <button 179 onClick={() => setDescOpen((v) => !v)} 180 className="slide-meta hover:text-foreground transition-colors flex items-center gap-1.5" 181 > 182 {descOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} 183 PR description 184 </button> 185 {descOpen && ( 186 <div className="mt-3 ml-4 max-h-64 overflow-y-auto border-l border-border pl-4"> 187 <Markdown className="text-sm text-muted-foreground leading-relaxed">{review.prDescription}</Markdown> 188 </div> 189 )} 190 </section> 191 )} 192 193 {/* Web Sources — same pattern as PR description. */} 194 {review.webSources && review.webSources.length > 0 && ( 195 <section className="animate-fade-in-up" style={{ animationDelay: '180ms' }}> 196 <button 197 onClick={() => setSourcesOpen((v) => !v)} 198 className="slide-meta hover:text-foreground transition-colors flex items-center gap-1.5" 199 > 200 {sourcesOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} 201 Web sources ({review.webSources.length}) 202 </button> 203 {sourcesOpen && ( 204 <ul className="mt-3 ml-4 flex flex-col gap-1.5 border-l border-border pl-4"> 205 {review.webSources.map((source, i) => ( 206 <li key={i}> 207 <button 208 onClick={() => window.electronAPI.openExternal(source.url)} 209 className="text-sm text-[var(--ring)] hover:underline truncate max-w-full text-left" 210 title={source.url} 211 > 212 {source.title || source.url} 213 </button> 214 </li> 215 ))} 216 </ul> 217 )} 218 </section> 219 )} 220 221 {/* Start reading — the explicit "click here to begin" 222 affordance. The bottom nav also surfaces this as 223 "Begin reading: NN — title", but having the same call 224 on the page itself catches users who scroll the prose 225 without noticing the bottom bar. */} 226 {firstSlide && ( 227 <section 228 className="animate-fade-in-up pt-6 mt-2 border-t border-border" 229 style={{ animationDelay: '240ms' }} 230 > 231 <button 232 onClick={() => onNavigate(firstSlide.slideNumber)} 233 className="group flex items-baseline gap-3 text-left" 234 > 235 <span className="slide-meta">Start reading</span> 236 <span className="font-serif text-lg text-foreground group-hover:opacity-80 transition-opacity"> 237 {firstSlide.slideNumber.toString().padStart(2, '0')} — {firstSlide.title} → 238 </span> 239 </button> 240 </section> 241 )} 242 243 {/* Remaining changes — files in the PR that the AI didn't 244 feature in any slide. Collapsed by default so they don't 245 distract, but visible enough that the reviewer knows 246 they exist and can inspect them. Ensures 100% coverage. */} 247 {remainingFiles.length > 0 && ( 248 <section 249 className="animate-fade-in-up pt-6 mt-2 border-t border-border" 250 style={{ animationDelay: '300ms' }} 251 > 252 <button 253 onClick={() => setRemainingOpen((v) => !v)} 254 className="slide-meta hover:text-foreground transition-colors flex items-center gap-1.5" 255 > 256 {remainingOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} 257 {remainingFiles.length} {remainingFiles.length === 1 ? 'file' : 'files'} not featured in the walkthrough 258 </button> 259 {remainingOpen && ( 260 <ul className="mt-3 ml-4 flex flex-col gap-1 border-l border-border pl-4"> 261 {remainingFiles.map((f) => ( 262 <li key={f.filename} className="slide-meta flex items-center gap-3"> 263 <span className="truncate">{f.filename}</span> 264 <span className="shrink-0 opacity-60"> 265 +{f.additions} −{f.deletions} 266 </span> 267 </li> 268 ))} 269 </ul> 270 )} 271 </section> 272 )} 273 </div> 274 </div> 275 ); 276 }