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

234 lines9,792 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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