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

148 lines6,544 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 { Sparkles } from "lucide-react";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look3import { Form, Link, redirect, useNavigation } from "react-router";
Agents as a team: lifecycle, merge queue, billing and a new shell4
5import type { Plan } from "@g1t/contracts";
6
7import type { Route } from "./+types/plans";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look8import { refusal, requireRepo } from "../../lib/access.server";
9import { whyNot } from "../../lib/access";
Social cards for every page: og.g1t.sh10import { page } from "../../lib/meta";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look11import { Button, ComputeNote, EmptyState, ErrorText, Textarea, TimeAgo } from "../../components/ui";
12import { computeNoteFor } from "../../lib/compute.server";
Agents as a team: lifecycle, merge queue, billing and a new shell13import { work } from "../../lib/services.server";
14import {
15 assertSameOrigin,
16 getViewer,
17 requireUser,
18 unwrap,
19} from "../../lib/session.server";
20
Social cards for every page: og.g1t.sh21export function meta({ params, ...args }: Route.MetaArgs) {
22 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell23}
24
25export async function loader({ params, context }: Route.LoaderArgs) {
26 const viewer = getViewer(context);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look27 // Anyone who can read the repository: a public one's to anyone, a private one's to people with a role.
28 const { access } = await requireRepo(context, params, "read");
Agents as a team: lifecycle, merge queue, billing and a new shell29 const path = { namespace: params.owner, name: params.repo };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look30 const [plans, computeNote] = await Promise.all([
31 work.listPlans(path, viewer),
32 // Planning is an agent run: said before it is refused for the plan.
33 computeNoteFor(params.owner, "agent"),
34 ]);
35 return { plans: unwrap(plans), computeNote, can: access.can };
Agents as a team: lifecycle, merge queue, billing and a new shell36}
37
38export async function action({ request, params, context }: Route.ActionArgs) {
39 assertSameOrigin(request);
40 const user = requireUser(context, request);
41 const form = await request.formData();
42 const path = { namespace: params.owner, name: params.repo };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look43 // Planning is an agent run: Write and up.
44 const refused = await refusal(context, params, "run");
45 if (refused) return { error: refused };
Agents as a team: lifecycle, merge queue, billing and a new shell46 const started = await env.RUNNER.plan(user, path, String(form.get("brief") ?? ""));
47 if (!started.ok) return { error: started.error.message };
48 throw redirect(`/${params.owner}/${params.repo}/plans/${started.value.planId}`);
49}
50
51const STATUS: Record<Plan["status"], string> = {
52 planning: "Being written",
53 ready: "Ready to read",
54 failed: "Could not be written",
55 applied: "Applied",
56};
57
58export default function Plans({ loaderData, actionData, params }: Route.ComponentProps) {
59 const { plans } = loaderData;
60 const base = `/${params.owner}/${params.repo}`;
61 const starting = useNavigation().state === "submitting";
62 return (
63 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
64 <div className="min-w-0">
65 <h2 className="text-xl font-semibold tracking-tight">Plan work</h2>
66 <p className="mt-1 max-w-2xl text-sm text-muted">
67 Say what you want to be true when the work is done. An agent reads this
68 repository and proposes the issues that would get there, with what each
69 must pass and which have to land before which. You read it, change what
70 you like, and put g1t agents on it with one click.
71 </p>
72 <Form method="post" className="mt-5 space-y-3">
73 <Textarea
74 name="brief"
75 rows={7}
76 required
77 placeholder={
78 "The outcome, in your own words. For example:\n\nThe greeter should support a --lang flag for Spanish and French, a --shout flag that upper-cases the greeting, and a --version flag. Each should be documented in the README and covered by tests."
79 }
80 />
81 <div className="flex flex-wrap items-center gap-3">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look82 <Button variant="accent" type="submit" disabled={starting || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
Agents as a team: lifecycle, merge queue, billing and a new shell83 <Sparkles size={15} />
84 {starting ? "Starting the planner…" : "Plan it"}
85 </Button>
86 <span className="text-xs text-muted">
87 Nothing is opened until you have read the plan.
88 </span>
89 </div>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look90 {actionData?.error ? <ErrorText>{actionData.error}</ErrorText> : <ComputeNote note={loaderData.computeNote} />}
Agents as a team: lifecycle, merge queue, billing and a new shell91 </Form>
92
93 <h3 className="mt-10 text-sm font-medium text-muted">Plans</h3>
94 <div className="mt-3">
95 {plans.length === 0 ? (
96 <EmptyState title="No plans yet" />
97 ) : (
98 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
99 {plans.map((plan) => (
100 <li key={plan.id}>
101 <Link
102 to={`${base}/plans/${plan.id}`}
103 className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-surface"
104 >
105 <span className="min-w-0 grow">
106 <span className="block truncate font-medium">{plan.brief}</span>
107 <span className="mt-0.5 block text-xs text-faint">
108 {plan.author.username} · <TimeAgo at={plan.createdAt} />
109 {plan.issues.length > 0 &&
110 ` · ${plan.issues.length} ${plan.issues.length === 1 ? "issue" : "issues"}`}
111 </span>
112 </span>
113 <span
114 className={`shrink-0 text-xs ${
115 plan.status === "failed"
116 ? "text-danger"
117 : plan.status === "ready"
118 ? "text-accent"
119 : "text-muted"
120 }`}
121 >
122 {STATUS[plan.status]}
123 </span>
124 </Link>
125 </li>
126 ))}
127 </ul>
128 )}
129 </div>
130 </div>
131
132 <aside className="space-y-5 text-sm">
133 <section className="rounded-xl border border-line bg-surface p-5">
134 <h3 className="font-medium">What a plan gives you</h3>
135 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
136 <li>Issues small enough to be merged one at a time, written so that an agent needs nothing else.</li>
137 <li>Acceptance checks on each, taken from how this repository is tested.</li>
138 <li>The files each will touch, and a dependency wherever two would collide.</li>
139 <li>
140 Independent issues are worked on at the same time. One that depends on
141 another starts when that has merged, from its result.
142 </li>
143 </ul>
144 </section>
145 </aside>
146 </div>
147 );
148}