Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/review.tsx

Snapshot at 34e3175

Containing directory

27,905 bytes · textView raw
1import { WriteHint, staleReason } from "./freshness.tsx";
2import { ReviewAdvice } from "./judge.tsx";
3import { ArchivedBadge } from "./archive.tsx";
4import { UsageFigures } from "./usage.tsx";
5import { useEffect, useRef, useState, type ReactNode } from "react";
6import { Badge, Banner, Button, ClipboardText, Dialog, Tooltip, Textarea } from "@cloudflare/kumo";
7import { ArrowUUpLeft, Copy, Warning } from "@phosphor-icons/react";
8import type { Review, ReviewDetail, ReviewDiff } from "./contracts.ts";
9import { blockerView, blockerGatesReview, reviewState, reviewView, type ViewModel } from "./model.ts";
10import { type ViewActions, ReviewActions } from "./view.tsx";
11import { CollisionBanner, CollisionRows } from "./collisions.tsx";
12import { ApiError } from "./transport.ts";
13import { checkCount, duration, ReceiptBadge } from "./receipts.tsx";
14import { relativeTime } from "./time.ts";
15
16function Dot({ children, tone = "neutral" }: { children: ReactNode; tone?: string }) {
17  return (
18    <Badge appearance="dot" variant="neutral" className={`status-dot status-${tone}`}>
19      {children}
20    </Badge>
21  );
22}
23export function checksWord(review: Review): string {
24  switch (review.receipt?.status) {
25    case "passed":
26      return "Checks passed";
27    case "failed":
28      return "Checks failed";
29    case "running":
30      return "Checks running";
31    case "timed-out":
32      return "Timed out";
33    case "infrastructure-error":
34      return "Couldn't run";
35    default:
36      return "No receipt";
37  }
38}
39function checkTone(review: Review) {
40  switch (review.receipt?.status) {
41    case "passed":
42      return "success";
43    case "failed":
44      return "danger";
45    case "running":
46      return "info";
47    case "timed-out":
48    case "infrastructure-error":
49      return "warning";
50    default:
51      return "neutral";
52  }
53}
54
55const strings = (v: unknown): v is string[] =>
56  Array.isArray(v) && v.every((x) => typeof x === "string");
57
58/** Refuse malformed/unbounded detail instead of substituting packet or push-history evidence. */
59export function assertDetail(value: ReviewDetail, id: string): void {
60  const c = value?.changes;
61  const r = value?.review;
62  if (
63    !r ||
64    r.id !== id ||
65    typeof r.sha !== "string" ||
66    typeof r.base !== "string" ||
67    !value.packet ||
68    value.packet.id !== r.packet ||
69    typeof value.packet.title !== "string" ||
70    typeof value.packet.goal !== "string" ||
71    !strings(value.packet.acceptance) ||
72    !strings(value.packet.paths) ||
73    !c ||
74    c.head !== r.sha ||
75    c.base !== r.base ||
76    typeof c.truncated !== "boolean" ||
77    !Array.isArray(c.files) ||
78    c.files.length > 200 ||
79    !Number.isSafeInteger(c.fileCount) ||
80    c.fileCount < c.files.length ||
81    !c.files.every(
82      (f) => typeof f.path === "string" && ["added", "modified", "deleted"].includes(f.status),
83    ) ||
84    !Array.isArray(value.commits) ||
85    value.commits.length > 50 ||
86    typeof value.commitsTruncated !== "boolean" ||
87    !value.commits.every(
88      (commit) =>
89        typeof commit.sha === "string" &&
90        typeof commit.subject === "string" &&
91        typeof commit.authorName === "string" &&
92        (commit.packetTrailer === null || typeof commit.packetTrailer === "string") &&
93        (commit.agentTrailer === null || typeof commit.agentTrailer === "string"),
94    ) ||
95    !Array.isArray(value.blockers) ||
96    !value.blockers.every(
97      (b) =>
98        typeof b.id === "string" &&
99        typeof b.pushingAgent === "string" &&
100        typeof b.claimingAgent === "string" &&
101        strings(b.paths),
102    ) ||
103    (r.returned != null &&
104      (typeof r.returned.reason !== "string" ||
105        r.returned.reason.length < 1 ||
106        r.returned.reason.length > 2000 ||
107        typeof r.returned.at !== "string")) ||
108    (r.approval !== null && (!r.approval || typeof r.approval.sha !== "string")) ||
109    (value.promotion !== null &&
110      (!value.promotion ||
111        typeof value.promotion.before !== "string" ||
112        typeof value.promotion.after !== "string" ||
113        typeof value.promotion.recordedAt !== "string" ||
114        (value.promotion.receiptDigest !== null &&
115          typeof value.promotion.receiptDigest !== "string")))
116  ) {
117    throw new Error("Couldn't load this review.");
118  }
119}
120function FileDiff({
121  file,
122  id,
123  actions,
124  revision,
125}: {
126  file: ReviewDetail["changes"]["files"][number];
127  id: string;
128  actions: ViewActions;
129  revision: string;
130}) {
131  const [open, setOpen] = useState(false);
132  const [diff, setDiff] = useState<ReviewDiff>();
133  const [error, setError] = useState<string>();
134  useEffect(() => {
135    if (!open || diff || error) return;
136    let active = true;
137    if (!actions.getDiff) {
138      setError("Couldn't load this diff.");
139      return;
140    }
141    void actions
142      .getDiff(id, file.path)
143      .then((result) => {
144        if (
145          result.path !== file.path ||
146          result.status !== file.status ||
147          typeof result.binary !== "boolean" ||
148          typeof result.truncated !== "boolean" ||
149          typeof result.patch !== "string" ||
150          new TextEncoder().encode(result.patch).length > 65536
151        )
152          throw new Error("Invalid diff response");
153        if (active) setDiff(result);
154      })
155      .catch((e) => {
156        if (active)
157          setError(
158            e instanceof ApiError && e.status === 404
159              ? "This file isn't part of this review's changes."
160              : "Couldn't load this diff.",
161          );
162      });
163    return () => {
164      active = false;
165    };
166    // Identity/revision is keyed by the parent. A rerender must not duplicate a read.
167  }, [open, id, file.path, revision]);
168  return (
169    <div className="review-file">
170      <button
171        type="button"
172        className="review-file-toggle"
173        aria-label={`${file.status[0].toUpperCase() + file.status.slice(1)}, ${file.path}`}
174        aria-expanded={open}
175        onClick={() => setOpen(!open)}
176      >
177        <span className="caption file-status">
178          {file.status[0].toUpperCase() + file.status.slice(1)}
179        </span>
180        <span className="sr-only">, </span>
181        <Tooltip content={file.path} render={<code className="review-file-path" />}>
182          {file.path}
183        </Tooltip>
184      </button>
185      {open && (
186        <div className="review-diff caption" role="region" aria-label={`Diff for ${file.path}`}>
187          {error ? (
188            <p>{error}</p>
189          ) : !diff ? (
190            <p>Loading diff…</p>
191          ) : diff.binary ? (
192            <p>Binary file, not shown.</p>
193          ) : (
194            <pre>
195              {diff.patch.split("\n").map((line, i) => (
196                <span
197                  key={i}
198                  className={`diff-line ${line.startsWith("@@") ? "diff-hunk" : line.startsWith("+") && !line.startsWith("+++") ? "diff-added" : line.startsWith("-") && !line.startsWith("---") ? "diff-removed" : ""}`}
199                >
200                  {line.startsWith("-") && !line.startsWith("---") ? `−${line.slice(1)}` : line}
201                </span>
202              ))}
203            </pre>
204          )}
205          {diff?.truncated && <p>Patch truncated at 64 KB.</p>}
206        </div>
207      )}
208    </div>
209  );
210}
211
212function ClaimedPaths({ paths }: { paths: string[] }) {
213  const [expanded, setExpanded] = useState(false);
214  return (
215    <>
216      <ul className="review-claimed-paths">
217        {paths.slice(0, expanded ? paths.length : 20).map((path, i) => (
218          <li key={i}>
219            <code>{path}</code>
220          </li>
221        ))}
222      </ul>
223      {!expanded && paths.length > 20 && (
224        <Button variant="ghost" size="sm" onClick={() => setExpanded(true)}>
225          Show all {paths.length} paths
226        </Button>
227      )}
228    </>
229  );
230}
231
232function CommitCopy({ sha }: { sha: string }) {
233  const [message, setMessage] = useState("");
234  return (
235    <>
236      <Tooltip
237        content="Copy full SHA"
238        render={
239          <Button
240            variant="ghost"
241            size="sm"
242            shape="square"
243            aria-label={`Copy commit ${sha.slice(0, 7)}`}
244            onClick={async () => {
245              try {
246                await navigator.clipboard.writeText(sha);
247                setMessage("Full SHA copied.");
248              } catch {
249                setMessage("Couldn't copy the full SHA. Check clipboard access.");
250              }
251            }}
252          />
253        }
254      >
255        <Copy size={16} aria-hidden="true" />
256      </Tooltip>
257      <span role="status" className="sr-only">
258        {message}
259      </span>
260    </>
261  );
262}
263
264function Changes({
265  changes,
266  id,
267  actions,
268}: {
269  changes: ReviewDetail["changes"];
270  id: string;
271  actions: ViewActions;
272}) {
273  const [expanded, setExpanded] = useState(false);
274  const rows = useRef<HTMLDivElement>(null);
275  useEffect(() => {
276    if (expanded)
277      rows.current?.querySelectorAll<HTMLButtonElement>(".review-file-toggle")[20]?.focus();
278  }, [expanded]);
279  return (
280    <>
281      <div ref={rows}>
282        {changes.files.slice(0, expanded ? changes.files.length : 20).map((file) => (
283          <FileDiff
284            key={`${changes.base}:${changes.head}:${file.status}:${file.path}`}
285            file={file}
286            revision={changes.head}
287            id={id}
288            actions={actions}
289          />
290        ))}
291      </div>
292      {!expanded && changes.files.length > 20 && (
293        <Button variant="ghost" size="sm" onClick={() => setExpanded(true)}>
294          Show {changes.files.length - 20} more files
295        </Button>
296      )}
297      {changes.truncated && (expanded || changes.files.length <= 20) && (
298        <p className="caption muted">
299          Showing {changes.files.length} of {changes.fileCount} files.
300        </p>
301      )}
302    </>
303  );
304}
305
306function ReturnAction({
307  review,
308  view,
309  actions,
310  stale,
311}: {
312  review: Review;
313  view: ViewModel;
314  actions: ViewActions;
315  stale: boolean;
316}) {
317  const [open, setOpen] = useState(false);
318  const [reason, setReason] = useState("");
319  const [sending, setSending] = useState(false);
320  const [error, setError] = useState<string>();
321  const [conflict, setConflict] = useState(false);
322  const textarea = useRef<HTMLTextAreaElement>(null);
323  const close = useRef<HTMLButtonElement>(null);
324  useEffect(() => {
325    if (conflict) close.current?.focus();
326  }, [conflict]);
327  const canReturn =
328    actions.owner &&
329    actions.returnReview &&
330    !stale &&
331    review.status !== "stale" &&
332    !review.returned &&
333    review.returned !== undefined &&
334    review.status !== "promoted";
335  // Leave an in-flight conflict dialog mounted when the refreshed review became promoted.
336  if (!canReturn && !open) return null;
337  return (
338    <>
339      {canReturn && (
340        <WriteHint reason={staleReason(view)}><Button
341          variant="secondary"
342          size="sm"
343          data-action="return"
344          disabled={view.busy || view.stale}
345          onClick={() => {
346            if (view.busy || view.stale) return;
347            setReason("");
348            setError(undefined);
349            setConflict(false);
350            setOpen(true);
351          }}
352        >
353          <ArrowUUpLeft size={16} weight="bold" />
354          Return to agent
355        </Button></WriteHint>
356      )}
357      <Dialog.Root
358        open={open}
359        onOpenChange={(value) => {
360          if (!sending) setOpen(value);
361        }}
362      >
363        <Dialog className="p-6 return-dialog">
364          <Dialog.Title>Return to agent</Dialog.Title>
365          <Dialog.Description>
366            {review.agent} sees your reason and can push a fix. A returned review can't be approved
367            or promoted.
368          </Dialog.Description>
369          <label htmlFor="return-reason">What needs to change?</label>
370          <Textarea
371            ref={textarea}
372            id="return-reason"
373            required
374            minLength={1}
375            maxLength={2000}
376            value={reason}
377            aria-describedby="return-counter"
378            disabled={sending}
379            readOnly={conflict}
380            onChange={(e) => setReason(e.target.value)}
381          />
382          <p id="return-counter" className="caption muted" aria-live="polite">
383            {reason.length} / 2000
384          </p>
385          {error && (
386            <p role="alert" className="receipt-error-line">
387              <Warning size={20} aria-hidden="true" />
388              <span>{error}</span>
389            </p>
390          )}
391          <div className="dialog-actions">
392            <Button
393              ref={close}
394              variant="secondary"
395              disabled={sending}
396              onClick={() => setOpen(false)}
397            >
398              {conflict ? "Close" : "Cancel"}
399            </Button>
400            {!conflict && (
401              <WriteHint reason={staleReason(view)}><Button
402                variant="primary"
403                data-action="confirm-return"
404                disabled={view.busy || view.stale || sending || !canReturn || reason.length < 1 || reason.length > 2000}
405                onClick={async () => {
406                  if (view.busy || view.stale || sending || !actions.returnReview) return;
407                  setSending(true);
408                  setError(undefined);
409                  try {
410                    if (await actions.returnReview(review.id, reason)) setOpen(false);
411                    else setError("Review changed. Close and review again.");
412                  } catch (e) {
413                    setConflict(e instanceof ApiError && e.status === 409);
414                    setError(
415                      e instanceof ApiError && e.status === 409
416                        ? "This review was promoted, so it can't be returned."
417                        : "Couldn't return this review. Refresh and review again.",
418                    );
419                  } finally {
420                    setSending(false);
421                  }
422                }}
423              >
424                {sending ? "Returning…" : "Return to agent"}
425              </Button></WriteHint>
426            )}
427          </div>
428        </Dialog>
429      </Dialog.Root>
430    </>
431  );
432}
433
434export function ReviewView({
435  id,
436  view,
437  actions,
438  openReceipt,
439}: {
440  id: string;
441  view: ViewModel;
442  actions: ViewActions;
443  openReceipt(id: string): void;
444}) {
445  const [detail, setDetail] = useState<ReviewDetail>();
446  const [error, setError] = useState<"error" | "not-found">();
447  const [slow, setSlow] = useState(false);
448  const [diagnosticId, setDiagnosticId] = useState<string>();
449  const [attempt, setAttempt] = useState(0);
450  const [ledger, setLedger] = useState(false);
451  const snapshot = view.state;
452  useEffect(() => {
453    let active = true;
454    setError(undefined);
455    setSlow(false);
456    setDiagnosticId(undefined);
457    if (!actions.getReview) {
458      actions.openReview();
459      return;
460    }
461    const timer = setTimeout(() => { if (active) setSlow(true); }, 3000);
462    void actions
463      .getReview(id)
464      .then((value) => {
465        assertDetail(value, id);
466        if (active) {
467          setDetail(value);
468          setSlow(false);
469        }
470      })
471      .catch((e) => {
472        if (active) {
473          setError(e instanceof ApiError && e.status === 404 ? "not-found" : "error");
474          setDiagnosticId(e instanceof ApiError ? e.diagnosticId : undefined);
475          setSlow(false);
476        }
477      })
478      .finally(() => clearTimeout(timer));
479    return () => {
480      active = false;
481      clearTimeout(timer);
482    };
483  }, [id, snapshot, attempt]);
484  const raw = detail?.review;
485  const publicReview = raw && reviewView(raw, snapshot?.project.settings?.requireReceipt);
486  const current = snapshot?.reviews.find((r) => r.id === id);
487  const review =
488    publicReview && current
489      ? {
490          ...publicReview,
491          returned: current.returned ?? publicReview.returned,
492          status:
493            current.sha !== publicReview.sha || publicReview.status === "stale"
494              ? ("stale" as const)
495              : current.status,
496          approval: current.approval === undefined ? publicReview.approval : current.approval,
497        }
498      : publicReview;
499  const cause = review && snapshot && reviewState(review, snapshot);
500  const stale = !!cause?.banner;
501  // Re-use queue gates against the detail's exact public review; never authorize from fixtures/history.
502  const detailView =
503    review && snapshot
504      ? {
505          ...view,
506          state: {
507            ...snapshot,
508            reviews: snapshot.reviews.map((r) => (r.id === id ? review : r)),
509            blockers: [
510              ...new Map(
511                [
512                  ...(detail?.blockers ?? []).map(blockerView),
513                  ...snapshot.blockers,
514                ].map((blocker) => [blocker.id, blocker]),
515              ).values(),
516            ],
517          },
518        }
519      : view;
520  const back = (
521    <Button variant="ghost" size="sm" onClick={() => actions.openReview()}>
522      ← Review queue
523    </Button>
524  );
525  if (error)
526    return (
527      <section className="review-detail">
528        {back}
529        <h2>Review</h2>
530        <p>
531          {error === "not-found"
532            ? "This review doesn't exist anymore."
533            : "Couldn't load this review."}
534        </p>
535        {error === "error" && <p className="caption muted">
536          Reading the fork's repository failed. Try again in a moment.
537        </p>}
538        {actions.owner && diagnosticId && <p className="caption muted">Diagnostic ID <code>{diagnosticId}</code></p>}
539        {error === "error" && (
540          <Button variant="secondary" onClick={() => setAttempt(attempt + 1)}>
541            Try again
542          </Button>
543        )}
544      </section>
545    );
546  if (!detail || !review)
547    return (
548      <section className="review-detail">
549        {back}
550        <div className="review-skeleton" aria-hidden="true" />
551        <p className="caption muted" role="status">
552          Loading review…
553        </p>
554        {slow && <p className="caption muted">The first open of a review reads its commit from the fork and can take up to 30 s. Later opens are quick.</p>}
555      </section>
556    );
557  const approval = !!review.approval;
558  const receipt = review.receipt;
559  const word = checksWord(review);
560  const promotion = detail.promotion;
561  const packet = snapshot?.packets.find((entry) => entry.id === detail.packet.id) ?? detail.packet;
562  return (
563    <article className="review-detail">
564      {back}
565      <header className="review-header">
566        <div>
567          <h2>{detail.packet.title}</h2>
568          <p className="caption muted">
569            <code>{detail.packet.id}</code>{" "}
570            {packet.archived && <ArchivedBadge at={packet.archived.at} />} · by {review.agent} · <code>{review.repoName}</code>
571            {review.requestedAt && ` · requested ${relativeTime(review.requestedAt)}`}
572          </p>
573          <div className="review-usage caption"><span><strong>Usage</strong> · Tokens to this review:</span><UsageFigures usage={review.usageAtReview} agent={review.agent} brand={view.brand} split /></div>
574          <ReviewAdvice state={snapshot} review={review} />
575        </div>
576        {snapshot && (
577          <div className="actions review-actions">
578            {current && !stale && !review.returned && (
579              <ReviewActions
580                review={review}
581                view={detailView}
582                actions={actions}
583                openReceipt={() => openReceipt(id)}
584              />
585            )}
586            <ReturnAction review={review} view={detailView} actions={actions} stale={!!stale} />
587          </div>
588        )}
589      </header>
590      {stale && (
591        <Banner
592          variant="alert"
593          icon={<Warning size={20} />}
594          description={
595            cause?.kind === "main-moved" ? (
596              <>
597                <code>main</code> moved to <code>{cause.mainSha!.slice(0, 7)}</code> after this review
598                was requested. The agent must rebase and request review again.
599              </>
600            ) : cause?.banner
601          }
602        />
603      )}
604      <div className="review-milestones">
605        <div className="review-milestone">
606          <p className="caption muted">Checks</p>
607          <Dot tone={checkTone(review)}>{word}</Dot>
608          {receipt ? (
609            <button
610              type="button"
611              className="review-evidence caption"
612              aria-label={`${word}. Open the receipt.`}
613              onClick={() => openReceipt(id)}
614            >
615              {checkCount(receipt.checks.length)}
616              {duration(receipt.durationMs) && ` · ${duration(receipt.durationMs)}`}
617              {receipt.digest && (
618                <>
619                  {" "}
620                  · <code>{receipt.digest.slice(0, 7)}</code>
621                </>
622              )}
623            </button>
624          ) : null}
625          {snapshot?.project.settings?.requireReceipt === false && receipt?.status === "failed" && (
626            <p className="caption muted">Advisory: this didn't block approval.</p>
627          )}
628        </div>
629        <div className="review-milestone">
630          <p className="caption muted">Approval</p>
631          <Dot tone={review.returned ? "danger" : approval ? "success" : "neutral"}>
632            {review.returned ? "Returned to agent" : approval ? "Approved" : "Not approved yet"}
633          </Dot>
634          {review.returned ? (
635            <button
636              type="button"
637              className="review-evidence caption"
638              aria-label="Returned to agent. Read the full reason."
639              onClick={() => {
640                document.getElementById("return-reason-full")?.scrollIntoView();
641                document.getElementById("return-reason-full")?.focus();
642              }}
643            >
644              {review.returned.reason.split(/\r?\n/)[0]}
645            </button>
646          ) : review.approval ? (
647            <button
648              type="button"
649              className="review-evidence caption"
650              aria-label="Approved. Inspect the approved commit."
651              onClick={() =>
652                document.getElementById(`review-commit-${review.approval!.sha}`)?.focus()
653              }
654            >
655              at <code>{review.approval.sha.slice(0, 7)}</code>
656            </button>
657          ) : (
658            <p className="caption muted">No approval recorded.</p>
659          )}
660        </div>
661        <div className="review-milestone">
662          <p className="caption muted">Main</p>
663          <Dot tone={promotion || review.status === "promoted" ? "success" : "neutral"}>
664            {promotion || review.status === "promoted" ? "Promoted to main" : "Not promoted yet"}
665          </Dot>
666          {promotion ? (
667            <button
668              type="button"
669              className="review-evidence caption"
670              aria-label="Promoted to main. Inspect the ledger entry."
671              onClick={() => setLedger(true)}
672            >
673              <code>
674                {promotion.before.slice(0, 7)} → {promotion.after.slice(0, 7)}
675              </code>{" "}
676              · {relativeTime(promotion.recordedAt)}
677              {promotion.receiptDigest && (
678                <>
679                  {" "}
680                  · receipt <code>{promotion.receiptDigest.slice(0, 7)}</code>
681                </>
682              )}
683            </button>
684          ) : (
685            <p className="caption muted">
686              {review.status === "promoted"
687                ? "No ledger entry recorded."
688                : "No promotion recorded."}
689            </p>
690          )}
691        </div>
692      </div>
693      {review.returned && (
694        <blockquote id="return-reason-full" tabIndex={-1} className="returned-reason">
695          <p className="plain-text">{review.returned.reason}</p>
696          <p className="caption muted">Returned {relativeTime(review.returned.at)}</p>
697        </blockquote>
698      )}
699      {promotion && (
700        <Dialog.Root open={ledger} onOpenChange={setLedger}>
701          <Dialog className="p-6">
702            <Dialog.Title>Promotion ledger</Dialog.Title>
703            <Dialog.Description>Recorded {relativeTime(promotion.recordedAt)}</Dialog.Description>
704            <p className="caption muted">Before</p>
705            <ClipboardText text={promotion.before} size="sm" />
706            <p className="caption muted">After</p>
707            <ClipboardText text={promotion.after} size="sm" />
708            {promotion.receiptDigest && (
709              <>
710                <p className="caption muted">Receipt</p>
711                <ClipboardText text={promotion.receiptDigest} size="sm" />
712              </>
713            )}
714            <Button variant="secondary" onClick={() => setLedger(false)}>
715              Close
716            </Button>
717          </Dialog>
718        </Dialog.Root>
719      )}
720      {(() => {
721        const collisions = detailView.state?.blockers.filter((b) => blockerGatesReview(b, review)) ?? [];
722        return collisions.length > 0 && <section className="blockers">
723          <h2>Collisions</h2>
724          {collisions.filter((b) => !b.resolved).map((b) =>
725            <CollisionBanner key={b.id} blocker={b} owner={actions.owner}
726              open={actions.openCollision} showPacket={actions.showPacket ?? (() => {})} disabled={view.busy || view.stale} disabledReason={staleReason(view)} />)}
727          <CollisionRows blockers={collisions.filter((b) => b.resolved)} owner={actions.owner} />
728        </section>;
729      })()}
730      <section>
731        <h2>Request</h2>
732        <p className="caption muted">Goal</p>
733        <p className="plain-text">{detail.packet.goal}</p>
734        <p className="caption muted">Acceptance</p>
735        <ul className="review-acceptance">
736          {detail.packet.acceptance.map((item, i) => (
737            <li key={i}>{item}</li>
738          ))}
739        </ul>
740        <p className="caption muted">Claimed paths</p>
741        <ClaimedPaths key={id} paths={detail.packet.paths} />
742      </section>
743      <section>
744        <h2>Changes</h2>
745        <p className="caption muted">
746          {detail.changes.fileCount} {detail.changes.fileCount === 1 ? "file" : "files"} changed
747        </p>
748        <Changes
749          key={`${id}:${detail.changes.base}:${detail.changes.head}`}
750          changes={detail.changes}
751          id={id}
752          actions={actions}
753        />
754      </section>
755      <section>
756        <h2>Commits</h2>
757        {detail.commits.map((commit) => (
758          <div
759            key={commit.sha}
760            id={`review-commit-${commit.sha}`}
761            tabIndex={-1}
762            className="review-commit"
763          >
764            <p>{commit.subject}</p>
765            <div className="caption muted review-commit-caption">
766              <code>{commit.sha.slice(0, 7)}</code>
767              <CommitCopy sha={commit.sha} /> · {commit.authorName} ·{" "}
768              {commit.packetTrailer && (
769                <Badge variant="secondary">Packet {commit.packetTrailer}</Badge>
770              )}{" "}
771              {commit.agentTrailer && (
772                <Badge variant="secondary">Agent {commit.agentTrailer}</Badge>
773              )}
774              {!commit.packetTrailer && !commit.agentTrailer && (
775                <span>No {view.brand.name} trailers</span>
776              )}
777            </div>
778          </div>
779        ))}
780        {detail.commitsTruncated && <p className="caption muted">Showing the latest 50 commits.</p>}
781      </section>
782      <section>
783        <h2>Receipt</h2>
784        <Dot tone={checkTone(review)}>{word}</Dot>
785        {receipt?.checks.map((check, i) => (
786          <div className="receipt-check" key={i}>
787            <code>{check.name}</code> · {check.tier} · <ReceiptBadge status={check.outcome} />
788            {duration(check.durationMs) && ` · ${duration(check.durationMs)}`}
789          </div>
790        ))}
791        {receipt && (
792          <Button variant="ghost" size="sm" onClick={() => openReceipt(id)}>
793            Open receipt
794          </Button>
795        )}
796      </section>
797    </article>
798  );
799}
800