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