g1t/apps/web/app/routes/repo/plan.tsx

258 lines10,880 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { ArrowRight, FileCode2, Sparkles, Terminal } from "lucide-react";
3import { useEffect } from "react";
4import { Form, Link, data, useNavigation, useRevalidator } from "react-router";
5
6import type { Route } from "./+types/plan";
7import { Markdown } from "../../components/markdown";
8import { Outcome } from "../../components/outcome";
9import { Button, ErrorText, TimeAgo } from "../../components/ui";
10import { Label } from "../../components/work";
11import { billing, work } from "../../lib/services.server";
12import {
13 assertSameOrigin,
14 getViewer,
15 requireUser,
16 roleIn,
17 unwrap,
18} from "../../lib/session.server";
19
20const REFRESH_MS = 4000;
21
22export function meta({ params }: Route.MetaArgs) {
23 return [{ title: `Plan · ${params.owner}/${params.repo} · g1t` }];
24}
25
26export async function loader({ params, context }: Route.LoaderArgs) {
27 const viewer = getViewer(context);
28 // Members only; to anyone else the page does not exist.
29 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
30 const path = { namespace: params.owner, name: params.repo };
31 const [plan, ledger] = await Promise.all([
32 work.getPlan(path, viewer, params.id),
33 billing.ledger(params.owner, viewer),
34 ]);
35 const found = unwrap(plan);
36 // What the agents working on this outcome have cost so far.
37 const pulls = new Set(found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])));
38 const repo = `${params.owner}/${params.repo}`;
39 const costMicros = ledger.ok
40 ? ledger.value
41 .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number))
42 .reduce((sum, entry) => sum - entry.amountMicros, 0)
43 : null;
44 return { plan: found, costMicros };
45}
46
47export async function action({ request, params, context }: Route.ActionArgs) {
48 assertSameOrigin(request);
49 const user = requireUser(context, request);
50 const form = await request.formData();
51 const path = { namespace: params.owner, name: params.repo };
52 const applied = await env.RUNNER.applyPlan(user, path, params.id, {
53 assign: form.get("action") === "assign",
54 keep: form.getAll("keep").map(Number),
55 });
56 return applied.ok ? null : { error: applied.error.message };
57}
58
59export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
60 const { plan, costMicros } = loaderData;
61 const base = `/${params.owner}/${params.repo}`;
62 const applying = useNavigation().state === "submitting";
63
64 // The agent is still writing it.
65 const revalidator = useRevalidator();
66 const planning = plan.status === "planning";
67 // Planning, or agents still converging what was applied.
68 const moving =
69 planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
70 useEffect(() => {
71 if (!moving) return;
72 const timer = setInterval(() => {
73 if (document.visibilityState === "visible") revalidator.revalidate();
74 }, REFRESH_MS);
75 return () => clearInterval(timer);
76 }, [moving, revalidator]);
77 const converging = plan.status === "applied" && plan.progress.length > 0;
78
79 const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
80 return (
81 <div className={converging ? "" : "max-w-4xl"}>
82 <p className="text-sm">
83 <Link to={`${base}/plans`} className="text-muted hover:text-fg">
84 Plans
85 </Link>
86 </p>
87 <h2 className="mt-2 text-xl font-semibold tracking-tight">The outcome</h2>
88 <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm whitespace-pre-wrap">
89 {plan.brief}
90 </div>
91 <p className="mt-2 text-xs text-faint">
92 Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} />
93 </p>
94
95 {converging && (
96 <section className="mt-8">
97 <Outcome plan={plan} base={base} costMicros={costMicros} />
98 </section>
99 )}
100
101 {planning && (
102 <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm">
103 <span className="size-2 animate-pulse rounded-full bg-accent" />
104 <span>
105 An agent is reading the repository and writing the plan. This takes a
106 minute or two; the page fills in when it is done.
107 </span>
108 </div>
109 )}
110 {plan.status === "failed" && (
111 <div className="mt-8 rounded-xl border border-danger/40 bg-danger/5 px-4 py-4 text-sm">
112 <p className="font-medium">The plan could not be written</p>
113 <p className="mt-1 text-muted">{plan.error}</p>
114 <p className="mt-3">
115 <Link to={`${base}/plans`} className="text-fg underline underline-offset-4">
116 Try again
117 </Link>
118 </p>
119 </div>
120 )}
121
122 {plan.issues.length > 0 && (
123 <Form method="post" className="mt-8">
124 <div className="flex flex-wrap items-baseline justify-between gap-2">
125 <h2 className="text-xl font-semibold tracking-tight">
126 {plan.status === "applied" ? "What was opened" : "The plan"}
127 </h2>
128 <p className="text-sm text-muted">
129 {plan.issues.length} {plan.issues.length === 1 ? "issue" : "issues"},{" "}
130 {independent} that can start at once
131 </p>
132 </div>
133 {plan.summary && <p className="mt-2 text-sm text-muted">{plan.summary}</p>}
134
135 <ol className="mt-5 space-y-3">
136 {plan.issues.map((issue, index) => {
137 const position = index + 1;
138 return (
139 <li
140 key={position}
141 className="flex gap-3 rounded-xl border border-line bg-surface p-4"
142 >
143 {plan.status === "ready" ? (
144 <input
145 type="checkbox"
146 name="keep"
147 value={position}
148 defaultChecked
149 aria-label={`Open issue ${position}`}
150 className="mt-1.5 accent-accent"
151 />
152 ) : (
153 <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint">
154 {position}
155 </span>
156 )}
157 <div className="min-w-0 grow">
158 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
159 {issue.number != null ? (
160 <Link
161 to={`${base}/issues/${issue.number}`}
162 className="font-medium hover:underline"
163 >
164 {issue.title}{" "}
165 <span className="font-normal text-faint">#{issue.number}</span>
166 </Link>
167 ) : (
168 <span className="font-medium">
169 <span className="mr-1.5 font-mono text-sm font-normal text-faint">
170 {position}.
171 </span>
172 {issue.title}
173 </span>
174 )}
175 {issue.labels.map((name) => (
176 <Label key={name} name={name} />
177 ))}
178 </div>
179 <p className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
180 {issue.dependsOn.length === 0 ? (
181 <span className="text-accent">Starts at once</span>
182 ) : (
183 <span className="flex items-center gap-1">
184 <ArrowRight size={12} />
185 After{" "}
186 {issue.dependsOn
187 .map((earlier) => {
188 const number = plan.issues[earlier - 1]?.number;
189 return number != null ? `#${number}` : `${earlier}`;
190 })
191 .join(", ")}
192 </span>
193 )}
194 {issue.checks.map((check) => (
195 <span key={check} className="flex items-center gap-1 font-mono">
196 <Terminal size={12} />
197 {check}
198 </span>
199 ))}
200 </p>
201 {issue.files.length > 0 && (
202 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs text-faint">
203 <FileCode2 size={12} />
204 {issue.files.map((file) => (
205 <span key={file}>{file}</span>
206 ))}
207 </p>
208 )}
209 <details className="mt-2">
210 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
211 What the agent will be told
212 </summary>
213 <div className="mt-2 border-t border-line pt-3">
214 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
215 </div>
216 </details>
217 </div>
218 </li>
219 );
220 })}
221 </ol>
222
223 {plan.status === "ready" && (
224 <div className="mt-6 flex flex-wrap items-center gap-3">
225 <Button variant="accent" type="submit" name="action" value="assign" disabled={applying}>
226 <Sparkles size={15} />
227 {applying ? "Opening issues…" : "Open these and assign g1t agents"}
228 </Button>
229 <Button variant="quiet" type="submit" name="action" value="open" disabled={applying}>
230 Only open the issues
231 </Button>
232 <span className="text-xs text-muted">
233 Untick any you do not want. Agents work on the independent ones at once
234 and the rest follow as what they depend on merges.
235 </span>
236 </div>
237 )}
238 {plan.status === "applied" && (
239 <p className="mt-6 text-sm text-muted">
240 These are open. Follow them under{" "}
241 <Link to={`${base}/issues`} className="text-fg underline underline-offset-4">
242 Issues
243 </Link>{" "}
244 or in{" "}
245 <Link to="/" className="text-fg underline underline-offset-4">
246 mission control
247 </Link>
248 .
249 </p>
250 )}
251 <div className="mt-3">
252 <ErrorText>{actionData?.error}</ErrorText>
253 </div>
254 </Form>
255 )}
256 </div>
257 );
258}