/ 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  }