Skip to content
122 linesCodeBlameRaw
1import { Hand, Loader } from "lucide-react";
2
3import type { Lifecycle, Stage } from "@g1t/contracts";
4import { Card } from "./ui/card";
5
6const STEPS = ["Change", "Checks", "Review", "Up to date", "Landing"];
7
8/** Which step a stage is at. A revision is the change being made again. */
9const STEP_OF: Record<Exclude<Stage, "needs_you">, number> = {
10 working: 0,
11 revising: 0,
12 checking: 1,
13 reviewing: 2,
14 catching_up: 3,
15 // Its change is made; it is answering another agent.
16 answering: 2,
17 ready: 4,
18 queued: 4,
19};
20
21/** A word or two for a stage, for lists. */
22export const STAGE_LABEL: Record<Stage, string> = {
23 working: "Making the change",
24 checking: "Running checks",
25 reviewing: "In review",
26 revising: "Revising",
27 catching_up: "Catching up",
28 answering: "Answering an agent",
29 queued: "In the merge queue",
30 ready: "Ready to merge",
31 needs_you: "Needs you",
32};
33
34/** Five dots showing how far along a pull request is. */
35export function StageDots({ stage }: { stage: Stage }) {
36 if (stage === "needs_you") return null;
37 const current = STEP_OF[stage];
38 return (
39 <span className="flex items-center gap-1" aria-hidden="true">
40 {STEPS.map((step, index) => (
41 <span
42 key={step}
43 className={`size-1.5 rounded-full ${
44 index < current || stage === "ready"
45 ? "bg-success"
46 : index === current
47 ? "animate-pulse bg-accent"
48 : "bg-line-strong"
49 }`}
50 />
51 ))}
52 </span>
53 );
54}
55
56const TITLE: Record<Stage, string> = {
57 working: "g1t is seeing this through",
58 checking: "g1t is seeing this through",
59 reviewing: "g1t is seeing this through",
60 revising: "g1t is seeing this through",
61 catching_up: "g1t is seeing this through",
62 answering: "g1t is seeing this through",
63 queued: "In the merge queue",
64 ready: "Ready to merge",
65 needs_you: "Needs you",
66};
67
68/**
69 * Where a pull request made by g1t stands while g1t is still seeing it
70 * through: the step it is on, as a five-part bar, and what is happening now.
71 * Once it is ready or queued the merge box says so, and this steps aside.
72 */
73export function LifecyclePanel({ lifecycle }: { lifecycle: Lifecycle }) {
74 const { stage, detail, revisions } = lifecycle;
75 if (stage === "ready" || stage === "queued") return null;
76 if (stage === "needs_you") {
77 return (
78 <div className="mt-4 flex gap-3 rounded-xl border border-warn/40 bg-warn/5 px-4 py-3 text-sm">
79 <Hand size={16} className="mt-0.5 shrink-0 text-warn" />
80 <div className="min-w-0">
81 <p className="font-medium">{TITLE[stage]}</p>
82 <p className="mt-0.5 text-muted">{detail}</p>
83 </div>
84 </div>
85 );
86 }
87 const current = STEP_OF[stage];
88 return (
89 <Card className="mt-4 px-4 py-3.5 text-sm">
90 <div className="flex items-center gap-2.5">
91 <Loader size={15} className="shrink-0 animate-spin text-accent motion-reduce:animate-none" />
92 <p className="font-medium">
93 {STAGE_LABEL[stage]}
94 <span className="font-normal text-faint"> · step {current + 1} of {STEPS.length}</span>
95 </p>
96 {revisions > 0 && (
97 <span className="ml-auto text-xs text-faint">Revised {revisions === 1 ? "once" : `${revisions} times`}</span>
98 )}
99 </div>
100 <ol className="mt-3 grid grid-cols-5 gap-1.5" aria-label="Steps">
101 {STEPS.map((step, index) => {
102 const done = index < current;
103 const active = index === current;
104 return (
105 <li key={step} aria-current={active ? "step" : undefined}>
106 <span
107 aria-hidden="true"
108 className={`block h-1 rounded-full ${
109 done ? "bg-success" : active ? "animate-pulse bg-success/60 motion-reduce:animate-none" : "bg-line"
110 }`}
111 />
112 <span className={`mt-1.5 block truncate text-[0.6875rem] ${active ? "font-medium text-fg" : done ? "text-muted" : "text-faint"}`}>
113 {step}
114 </span>
115 </li>
116 );
117 })}
118 </ol>
119 <p className="mt-2.5 text-muted">{detail}</p>
120 </Card>
121 );
122}