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

148 lines6,541 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Sparkles } from "lucide-react";
3import { Form, Link, redirect, useNavigation } from "react-router";
4
5import type { Plan } from "@g1t/contracts";
6
7import type { Route } from "./+types/plans";
8import { refusal, requireRepo } from "../../lib/access.server";
9import { whyNot } from "../../lib/access";
10import { page } from "../../lib/meta";
11import { Button, ComputeNote, EmptyState, ErrorText, Textarea, TimeAgo } from "../../components/ui";
12import { computeNoteFor } from "../../lib/compute.server";
13import { work } from "../../lib/services.server";
14import {
15 assertSameOrigin,
16 getViewer,
17 requireUser,
18 unwrap,
19} from "../../lib/session.server";
20
21export function meta({ params, ...args }: Route.MetaArgs) {
22 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
23}
24
25export async function loader({ params, context }: Route.LoaderArgs) {
26 const viewer = getViewer(context);
27 // 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");
29 const path = { namespace: params.owner, name: params.repo };
30 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 };
36}
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 };
43 // Planning is an agent run: Write and up.
44 const refused = await refusal(context, params, "run");
45 if (refused) return { error: refused };
46 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 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">
82 <Button variant="accent" type="submit" disabled={starting || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
83 <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>
90 {actionData?.error ? <ErrorText>{actionData.error}</ErrorText> : <ComputeNote note={loaderData.computeNote} />}
91 </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>What done looks like for each, in plain words, added to its description.</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}