flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

138 lines5,859 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";
3import { Form, Link, data, redirect, useNavigation } from "react-router";
4
5import type { Plan } from "@g1t/contracts";
6
7import type { Route } from "./+types/plans";
Social cards for every page: og.g1t.sh8import { page } from "../../lib/meta";
Agents as a team: lifecycle, merge queue, billing and a new shell9import { Button, EmptyState, ErrorText, Textarea, TimeAgo } from "../../components/ui";
10import { work } from "../../lib/services.server";
11import {
12 assertSameOrigin,
13 getViewer,
14 requireUser,
15 roleIn,
16 unwrap,
17} from "../../lib/session.server";
18
Social cards for every page: og.g1t.sh19export function meta({ params, ...args }: Route.MetaArgs) {
20 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell21}
22
23export async function loader({ params, context }: Route.LoaderArgs) {
24 const viewer = getViewer(context);
25 // Members only; to anyone else the page does not exist.
26 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
27 const path = { namespace: params.owner, name: params.repo };
28 return { plans: unwrap(await work.listPlans(path, viewer)) };
29}
30
31export async function action({ request, params, context }: Route.ActionArgs) {
32 assertSameOrigin(request);
33 const user = requireUser(context, request);
34 const form = await request.formData();
35 const path = { namespace: params.owner, name: params.repo };
36 const started = await env.RUNNER.plan(user, path, String(form.get("brief") ?? ""));
37 if (!started.ok) return { error: started.error.message };
38 throw redirect(`/${params.owner}/${params.repo}/plans/${started.value.planId}`);
39}
40
41const STATUS: Record<Plan["status"], string> = {
42 planning: "Being written",
43 ready: "Ready to read",
44 failed: "Could not be written",
45 applied: "Applied",
46};
47
48export default function Plans({ loaderData, actionData, params }: Route.ComponentProps) {
49 const { plans } = loaderData;
50 const base = `/${params.owner}/${params.repo}`;
51 const starting = useNavigation().state === "submitting";
52 return (
53 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
54 <div className="min-w-0">
55 <h2 className="text-xl font-semibold tracking-tight">Plan work</h2>
56 <p className="mt-1 max-w-2xl text-sm text-muted">
57 Say what you want to be true when the work is done. An agent reads this
58 repository and proposes the issues that would get there, with what each
59 must pass and which have to land before which. You read it, change what
60 you like, and put g1t agents on it with one click.
61 </p>
62 <Form method="post" className="mt-5 space-y-3">
63 <Textarea
64 name="brief"
65 rows={7}
66 required
67 placeholder={
68 "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."
69 }
70 />
71 <div className="flex flex-wrap items-center gap-3">
72 <Button variant="accent" type="submit" disabled={starting}>
73 <Sparkles size={15} />
74 {starting ? "Starting the planner…" : "Plan it"}
75 </Button>
76 <span className="text-xs text-muted">
77 Nothing is opened until you have read the plan.
78 </span>
79 </div>
80 <ErrorText>{actionData?.error}</ErrorText>
81 </Form>
82
83 <h3 className="mt-10 text-sm font-medium text-muted">Plans</h3>
84 <div className="mt-3">
85 {plans.length === 0 ? (
86 <EmptyState title="No plans yet" />
87 ) : (
88 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
89 {plans.map((plan) => (
90 <li key={plan.id}>
91 <Link
92 to={`${base}/plans/${plan.id}`}
93 className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-surface"
94 >
95 <span className="min-w-0 grow">
96 <span className="block truncate font-medium">{plan.brief}</span>
97 <span className="mt-0.5 block text-xs text-faint">
98 {plan.author.username} · <TimeAgo at={plan.createdAt} />
99 {plan.issues.length > 0 &&
100 ` · ${plan.issues.length} ${plan.issues.length === 1 ? "issue" : "issues"}`}
101 </span>
102 </span>
103 <span
104 className={`shrink-0 text-xs ${
105 plan.status === "failed"
106 ? "text-danger"
107 : plan.status === "ready"
108 ? "text-accent"
109 : "text-muted"
110 }`}
111 >
112 {STATUS[plan.status]}
113 </span>
114 </Link>
115 </li>
116 ))}
117 </ul>
118 )}
119 </div>
120 </div>
121
122 <aside className="space-y-5 text-sm">
123 <section className="rounded-xl border border-line bg-surface p-5">
124 <h3 className="font-medium">What a plan gives you</h3>
125 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
126 <li>Issues small enough to be merged one at a time, written so that an agent needs nothing else.</li>
127 <li>Acceptance checks on each, taken from how this repository is tested.</li>
128 <li>The files each will touch, and a dependency wherever two would collide.</li>
129 <li>
130 Independent issues are worked on at the same time. One that depends on
131 another starts when that has merged, from its result.
132 </li>
133 </ul>
134 </section>
135 </aside>
136 </div>
137 );
138}