| 1 | /** |
| 2 | * The pull requests for one issue, side by side, on each of them: how each |
| 3 | * stands, how its checks went, where its review is and how big it is, so |
| 4 | * two agents' attempts at the same issue can be compared and one chosen. |
| 5 | */ |
| 6 | import { CircleCheck, CircleDashed, CircleSlash, GitPullRequestArrow, MessageSquare } from "lucide-react"; |
| 7 | import { Suspense } from "react"; |
| 8 | import { Await, Link } from "react-router"; |
| 9 | |
| 10 | import { type Attempt, attemptOutcome } from "../lib/attempts"; |
| 11 | import { checksTally } from "../lib/commit-checks"; |
| 12 | import { CheckStateIcon } from "./commit-checks"; |
| 13 | import { ChangeSize } from "./work"; |
| 14 | import { PullIcon } from "./work-icons"; |
| 15 | import { SkeletonLine } from "./ui/skeleton"; |
| 16 | |
| 17 | function ReviewIcon({ state }: { state: Attempt["review"]["state"] }) { |
| 18 | if (state === "approved") return <CircleCheck size={14} className="shrink-0 text-success" />; |
| 19 | if (state === "changes_requested") return <CircleSlash size={14} className="shrink-0 text-danger" />; |
| 20 | if (state === "requested") return <MessageSquare size={14} className="shrink-0 text-warn" />; |
| 21 | return <CircleDashed size={14} className="shrink-0 text-faint" />; |
| 22 | } |
| 23 | |
| 24 | function AttemptRow({ attempt, base }: { attempt: Attempt; base: string }) { |
| 25 | const merged = attempt.status === "merged"; |
| 26 | return ( |
| 27 | <li className={`px-4 py-3 ${attempt.current ? "bg-raised/40" : ""}`}> |
| 28 | <div className="flex min-w-0 items-center gap-2"> |
| 29 | <PullIcon status={attempt.status} size={15} /> |
| 30 | {attempt.current ? ( |
| 31 | <span className="min-w-0 truncate font-medium"> |
| 32 | {attempt.title} <span className="font-normal text-faint">#{attempt.number}</span> |
| 33 | </span> |
| 34 | ) : ( |
| 35 | <Link to={`${base}/pull/${attempt.number}`} className="min-w-0 truncate font-medium hover:underline"> |
| 36 | {attempt.title} <span className="font-normal text-faint">#{attempt.number}</span> |
| 37 | </Link> |
| 38 | )} |
| 39 | {attempt.current && ( |
| 40 | <span className="shrink-0 rounded-full border border-line px-1.5 py-px text-[0.625rem] text-faint">This one</span> |
| 41 | )} |
| 42 | <span className="ml-auto shrink-0"> |
| 43 | <ChangeSize files={attempt.files} /> |
| 44 | </span> |
| 45 | </div> |
| 46 | <dl className="mt-2 grid gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3"> |
| 47 | <div className="flex min-w-0 items-center gap-1.5"> |
| 48 | <dt className="sr-only">State</dt> |
| 49 | <dd className={`truncate ${merged ? "font-medium text-merged" : "text-muted"}`}> |
| 50 | {attemptOutcome(attempt)} · by {attempt.author} |
| 51 | </dd> |
| 52 | </div> |
| 53 | <div className="flex min-w-0 items-center gap-1.5"> |
| 54 | <dt className="sr-only">Checks</dt> |
| 55 | <CheckStateIcon state={attempt.checks?.state ?? "none"} size={14} /> |
| 56 | <dd className="truncate text-muted">{attempt.checks && attempt.checks.total > 0 ? checksTally(attempt.checks) : "No checks"}</dd> |
| 57 | </div> |
| 58 | <div className="flex min-w-0 items-center gap-1.5"> |
| 59 | <dt className="sr-only">Review</dt> |
| 60 | <ReviewIcon state={attempt.review.state} /> |
| 61 | <dd className="truncate text-muted">{attempt.review.text}</dd> |
| 62 | </div> |
| 63 | </dl> |
| 64 | {!attempt.current && attempt.shared.length > 0 && ( |
| 65 | <p className="mt-1.5 truncate font-mono text-xs text-faint">Also changes {attempt.shared.join(", ")}</p> |
| 66 | )} |
| 67 | </li> |
| 68 | ); |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Every pull request for issue `issue`, this one first: streamed, so the |
| 73 | * page shows before the others are read. Nothing while there are no others. |
| 74 | */ |
| 75 | export function AttemptsBox({ attempts, issue, base }: { attempts: Promise<Attempt[]>; issue: number; base: string }) { |
| 76 | return ( |
| 77 | <Suspense |
| 78 | fallback={ |
| 79 | <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3" aria-busy> |
| 80 | <SkeletonLine className="text-sm" barClassName="w-48" /> |
| 81 | </div> |
| 82 | } |
| 83 | > |
| 84 | <Await resolve={attempts} errorElement={null}> |
| 85 | {(found) => |
| 86 | found.filter((attempt) => !attempt.current).length > 0 && ( |
| 87 | <section aria-label={`Other attempts at #${issue}`} className="mt-4 overflow-hidden rounded-xl border border-line bg-surface text-sm"> |
| 88 | <h3 className="flex items-center gap-2.5 px-4 pt-3 font-medium"> |
| 89 | <GitPullRequestArrow size={16} className="shrink-0 text-info" /> |
| 90 | Other attempts at #{issue} |
| 91 | </h3> |
| 92 | <p className="px-4 pt-0.5 pb-2 text-xs text-faint"> |
| 93 | Pull requests for the same issue. Merging one closes the others. |
| 94 | </p> |
| 95 | <ul className="divide-y divide-line border-t border-line"> |
| 96 | {found.map((attempt) => ( |
| 97 | <AttemptRow key={attempt.number} attempt={attempt} base={base} /> |
| 98 | ))} |
| 99 | </ul> |
| 100 | </section> |
| 101 | ) |
| 102 | } |
| 103 | </Await> |
| 104 | </Suspense> |
| 105 | ); |
| 106 | } |