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

301 lines13,609 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";
What happened across an outcome, as a feed beside its graph2import type { G1tEvent } from "@g1t/contracts";
Fast pages, required checks on the branch, self-hosted runners, honest incidents3import { ArrowRight, CircleCheck, FileCode2, Sparkles } from "lucide-react";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending4import { Form, Link, data, useNavigation } from "react-router";
Agents as a team: lifecycle, merge queue, billing and a new shell5
6import type { Route } from "./+types/plan";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look7import { refusal, requireRepo } from "../../lib/access.server";
8import { whyNot } from "../../lib/access";
Social cards for every page: og.g1t.sh9import { page } from "../../lib/meta";
Agents as a team: lifecycle, merge queue, billing and a new shell10import { Markdown } from "../../components/markdown";
Agents ask each other, hand each other work, and answer11import { Activity, Exchanges } from "../../components/activity";
Dark gray base with lavender as an accent, and a live outcome view for plans12import { Outcome } from "../../components/outcome";
Agents as a team: lifecycle, merge queue, billing and a new shell13import { Button, ErrorText, TimeAgo } from "../../components/ui";
Workspace names and icons, and a component kit for every control14import { Checkbox } from "../../components/ui/checkbox";
Agents as a team: lifecycle, merge queue, billing and a new shell15import { Label } from "../../components/work";
What happened across an outcome, as a feed beside its graph16import { billing, events, identity, work } from "../../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell17import {
18 assertSameOrigin,
19 getViewer,
20 requireUser,
21 unwrap,
22} from "../../lib/session.server";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending23import { useRefreshWhile } from "../../lib/refresh";
Agents as a team: lifecycle, merge queue, billing and a new shell24
25
Social cards for every page: og.g1t.sh26export function meta({ params, ...args }: Route.MetaArgs) {
27 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell28}
29
30export async function loader({ params, context }: Route.LoaderArgs) {
31 const viewer = getViewer(context);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look32 // Anyone who can read the repository: a public one's to anyone, a private one's to people with a role.
33 const { access } = await requireRepo(context, params, "read");
Agents as a team: lifecycle, merge queue, billing and a new shell34 const path = { namespace: params.owner, name: params.repo };
Dark gray base with lavender as an accent, and a live outcome view for plans35 const [plan, ledger] = await Promise.all([
36 work.getPlan(path, viewer, params.id),
37 billing.ledger(params.owner, viewer),
38 ]);
39 const found = unwrap(plan);
40 // What the agents working on this outcome have cost so far.
41 const pulls = new Set(found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])));
42 const repo = `${params.owner}/${params.repo}`;
43 const costMicros = ledger.ok
44 ? ledger.value
45 .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number))
46 .reduce((sum, entry) => sum - entry.amountMicros, 0)
47 : null;
What happened across an outcome, as a feed beside its graph48 // What happened across the outcome's issues and pull requests.
49 let activity: G1tEvent[] = [];
50 if (found.status === "applied" && found.progress.length > 0) {
51 const numbers = new Set([
52 ...found.progress.map((item) => item.number),
53 ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])),
54 ]);
55 const since = found.finishedAt ?? found.createdAt;
56 const recent = await events.list({ repoId: found.repoId, limit: 200 }).catch(() => []);
57 activity = recent
58 .filter((event) => event.time >= since)
59 .filter((event) => {
60 const data = event.data as { number?: number; issue?: number };
61 // Issues an agent filed while working on this outcome belong to it too.
62 if (event.type === "issue.opened" && event.actor === "usr_g1t_agent") return true;
63 return (data.number != null && numbers.has(data.number)) || (data.issue != null && numbers.has(data.issue));
64 })
65 .slice(0, 40);
66 // Events name accounts by id; show names.
67 const named = await identity
68 .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))])
69 .catch(() => ({}) as Record<string, string>);
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent70 const known: Record<string, string> = { ...named, usr_g1t_agent: "g1t", g1t_policy: "g1t" };
What happened across an outcome, as a feed beside its graph71 activity = activity.map((event) => ({ ...event, actor: event.actor ? (known[event.actor] ?? event.actor) : null }));
72 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look73 return { plan: found, costMicros, activity, can: access.can };
Agents as a team: lifecycle, merge queue, billing and a new shell74}
75
76export async function action({ request, params, context }: Route.ActionArgs) {
77 assertSameOrigin(request);
78 const user = requireUser(context, request);
79 const form = await request.formData();
80 const path = { namespace: params.owner, name: params.repo };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look81 // Opening the issues and putting agents on them: Write and up.
82 const refused = await refusal(context, params, "run");
83 if (refused) return { error: refused };
Agents as a team: lifecycle, merge queue, billing and a new shell84 const applied = await env.RUNNER.applyPlan(user, path, params.id, {
85 assign: form.get("action") === "assign",
86 keep: form.getAll("keep").map(Number),
87 });
88 return applied.ok ? null : { error: applied.error.message };
89}
90
91export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
What happened across an outcome, as a feed beside its graph92 const { plan, costMicros, activity } = loaderData;
Agents as a team: lifecycle, merge queue, billing and a new shell93 const base = `/${params.owner}/${params.repo}`;
94 const applying = useNavigation().state === "submitting";
95
96 // The agent is still writing it.
97 const planning = plan.status === "planning";
Dark gray base with lavender as an accent, and a live outcome view for plans98 // Planning, or agents still converging what was applied.
99 const moving =
100 planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending101 useRefreshWhile(moving);
Dark gray base with lavender as an accent, and a live outcome view for plans102 const converging = plan.status === "applied" && plan.progress.length > 0;
Agents as a team: lifecycle, merge queue, billing and a new shell103
104 const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
105 return (
Dark gray base with lavender as an accent, and a live outcome view for plans106 <div className={converging ? "" : "max-w-4xl"}>
Agents as a team: lifecycle, merge queue, billing and a new shell107 <p className="text-sm">
108 <Link to={`${base}/plans`} className="text-muted hover:text-fg">
109 Plans
110 </Link>
111 </p>
112 <h2 className="mt-2 text-xl font-semibold tracking-tight">The outcome</h2>
Polish: phones, copy boxes, the plan page, the landing page, a real glide113 <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm">
114 <Markdown source={plan.brief} repo={{ namespace: params.owner, name: params.repo }} />
Agents as a team: lifecycle, merge queue, billing and a new shell115 </div>
116 <p className="mt-2 text-xs text-faint">
117 Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} />
118 </p>
119
Dark gray base with lavender as an accent, and a live outcome view for plans120 {converging && (
121 <section className="mt-8">
122 <Outcome plan={plan} base={base} costMicros={costMicros} />
Agents ask each other, hand each other work, and answer123 {plan.exchanges.length > 0 && (
124 <div className="mt-10">
125 <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">Agents talking</h3>
126 <div className="mt-3 max-w-3xl">
127 <Exchanges exchanges={plan.exchanges} base={base} />
128 </div>
129 </div>
130 )}
What happened across an outcome, as a feed beside its graph131 {activity.length > 0 && (
132 <div className="mt-10">
133 <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">What happened</h3>
134 <div className="mt-3 max-w-3xl">
135 <Activity events={activity} base={base} />
136 </div>
137 </div>
138 )}
Dark gray base with lavender as an accent, and a live outcome view for plans139 </section>
140 )}
141
Agents as a team: lifecycle, merge queue, billing and a new shell142 {planning && (
143 <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm">
144 <span className="size-2 animate-pulse rounded-full bg-accent" />
145 <span>
146 An agent is reading the repository and writing the plan. This takes a
147 minute or two; the page fills in when it is done.
148 </span>
149 </div>
150 )}
151 {plan.status === "failed" && (
152 <div className="mt-8 rounded-xl border border-danger/40 bg-danger/5 px-4 py-4 text-sm">
153 <p className="font-medium">The plan could not be written</p>
154 <p className="mt-1 text-muted">{plan.error}</p>
155 <p className="mt-3">
156 <Link to={`${base}/plans`} className="text-fg underline underline-offset-4">
157 Try again
158 </Link>
159 </p>
160 </div>
161 )}
162
163 {plan.issues.length > 0 && (
164 <Form method="post" className="mt-8">
165 <div className="flex flex-wrap items-baseline justify-between gap-2">
166 <h2 className="text-xl font-semibold tracking-tight">
167 {plan.status === "applied" ? "What was opened" : "The plan"}
168 </h2>
169 <p className="text-sm text-muted">
170 {plan.issues.length} {plan.issues.length === 1 ? "issue" : "issues"},{" "}
171 {independent} that can start at once
172 </p>
173 </div>
174 {plan.summary && <p className="mt-2 text-sm text-muted">{plan.summary}</p>}
175
176 <ol className="mt-5 space-y-3">
177 {plan.issues.map((issue, index) => {
178 const position = index + 1;
179 return (
180 <li
181 key={position}
182 className="flex gap-3 rounded-xl border border-line bg-surface p-4"
183 >
184 {plan.status === "ready" ? (
Workspace names and icons, and a component kit for every control185 <Checkbox
Agents as a team: lifecycle, merge queue, billing and a new shell186 name="keep"
Workspace names and icons, and a component kit for every control187 value={String(position)}
Agents as a team: lifecycle, merge queue, billing and a new shell188 defaultChecked
189 aria-label={`Open issue ${position}`}
Workspace names and icons, and a component kit for every control190 className="mt-1"
Agents as a team: lifecycle, merge queue, billing and a new shell191 />
192 ) : (
193 <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint">
194 {position}
195 </span>
196 )}
197 <div className="min-w-0 grow">
198 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
199 {issue.number != null ? (
200 <Link
201 to={`${base}/issues/${issue.number}`}
202 className="font-medium hover:underline"
203 >
204 {issue.title}{" "}
205 <span className="font-normal text-faint">#{issue.number}</span>
206 </Link>
207 ) : (
208 <span className="font-medium">
209 <span className="mr-1.5 font-mono text-sm font-normal text-faint">
210 {position}.
211 </span>
212 {issue.title}
213 </span>
214 )}
215 {issue.labels.map((name) => (
216 <Label key={name} name={name} />
217 ))}
218 </div>
219 <p className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
220 {issue.dependsOn.length === 0 ? (
221 <span className="text-accent">Starts at once</span>
222 ) : (
223 <span className="flex items-center gap-1">
224 <ArrowRight size={12} />
225 After{" "}
226 {issue.dependsOn
227 .map((earlier) => {
228 const number = plan.issues[earlier - 1]?.number;
229 return number != null ? `#${number}` : `${earlier}`;
230 })
231 .join(", ")}
232 </span>
233 )}
Fast pages, required checks on the branch, self-hosted runners, honest incidents234 {(issue.done ?? []).map((point) => (
235 <span key={point} className="flex items-center gap-1">
236 <CircleCheck size={12} />
237 {point}
Agents as a team: lifecycle, merge queue, billing and a new shell238 </span>
239 ))}
240 </p>
241 {issue.files.length > 0 && (
242 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs text-faint">
243 <FileCode2 size={12} />
244 {issue.files.map((file) => (
245 <span key={file}>{file}</span>
246 ))}
247 </p>
248 )}
249 <details className="mt-2">
250 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
251 What the agent will be told
252 </summary>
253 <div className="mt-2 border-t border-line pt-3">
254 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
255 </div>
256 </details>
257 </div>
258 </li>
259 );
260 })}
261 </ol>
262
263 {plan.status === "ready" && (
264 <div className="mt-6 flex flex-wrap items-center gap-3">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look265 <Button variant="accent" type="submit" name="action" value="assign" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
Agents as a team: lifecycle, merge queue, billing and a new shell266 <Sparkles size={15} />
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent267 {applying ? "Opening issues…" : "Open these and assign g1t"}
Agents as a team: lifecycle, merge queue, billing and a new shell268 </Button>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look269 <Button variant="quiet" type="submit" name="action" value="open" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
Agents as a team: lifecycle, merge queue, billing and a new shell270 Only open the issues
271 </Button>
272 <span className="text-xs text-muted">
273 Untick any you do not want. Agents work on the independent ones at once
274 and the rest follow as what they depend on merges.
275 </span>
276 </div>
277 )}
Polish: phones, copy boxes, the plan page, the landing page, a real glide278 {plan.status === "applied" && !moving && (
279 <p className="mt-6 text-sm text-muted">Everything in this plan has landed.</p>
280 )}
281 {plan.status === "applied" && moving && (
Agents as a team: lifecycle, merge queue, billing and a new shell282 <p className="mt-6 text-sm text-muted">
Polish: phones, copy boxes, the plan page, the landing page, a real glide283 Some are still open. Follow them under{" "}
Agents as a team: lifecycle, merge queue, billing and a new shell284 <Link to={`${base}/issues`} className="text-fg underline underline-offset-4">
285 Issues
286 </Link>{" "}
287 or in{" "}
288 <Link to="/" className="text-fg underline underline-offset-4">
289 mission control
290 </Link>
291 .
292 </p>
293 )}
294 <div className="mt-3">
295 <ErrorText>{actionData?.error}</ErrorText>
296 </div>
297 </Form>
298 )}
299 </div>
300 );
301}