Skip to content

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

151 lines6,579 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Sparkles } from "lucide-react";
3import { Form, Link, redirect } 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 { ComputeNote, EmptyState, ErrorText, SubmitButton, Textarea, TimeAgo } from "../../components/ui";
12import { Hint } from "../../components/ui/hint";
13import { computeNoteFor } from "../../lib/compute.server";
14import { work } from "../../lib/services.server";
15import {
16 assertSameOrigin,
17 getViewer,
18 requireUser,
19 unwrap,
20} from "../../lib/session.server";
21
22export function meta({ params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
24}
25
26export async function loader({ params, context }: Route.LoaderArgs) {
27 const viewer = getViewer(context);
28 // Anyone who can read the repository: a public one's to anyone, a private one's to people with a role.
29 const { access } = await requireRepo(context, params, "read");
30 const path = { namespace: params.owner, name: params.repo };
31 const [plans, computeNote] = await Promise.all([
32 work.listPlans(path, viewer),
33 // Planning is an agent run: said before it is refused for the plan.
34 computeNoteFor(params.owner, "agent"),
35 ]);
36 return { plans: unwrap(plans), computeNote, can: access.can };
37}
38
39export async function action({ request, params, context }: Route.ActionArgs) {
40 assertSameOrigin(request);
41 const user = requireUser(context, request);
42 const form = await request.formData();
43 const path = { namespace: params.owner, name: params.repo };
44 // Planning is an agent run: Write and up.
45 const refused = await refusal(context, params, "run");
46 if (refused) return { error: refused };
47 const started = await env.RUNNER.plan(user, path, String(form.get("brief") ?? ""));
48 if (!started.ok) return { error: started.error.message };
49 throw redirect(`/${params.owner}/${params.repo}/plans/${started.value.planId}`);
50}
51
52const STATUS: Record<Plan["status"], string> = {
53 planning: "Being written",
54 ready: "Ready to read",
55 failed: "Could not be written",
56 applied: "Applied",
57};
58
59export default function Plans({ loaderData, actionData, params }: Route.ComponentProps) {
60 const { plans } = loaderData;
61 const base = `/${params.owner}/${params.repo}`;
62
63 return (
64 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
65 <div className="min-w-0">
66 <h2 className="text-xl font-semibold tracking-tight">Plan work</h2>
67 <p className="mt-1 max-w-2xl text-sm text-muted">
68 Say what you want to be true when the work is done. An agent reads this
69 repository and proposes the issues that would get there, with what each
70 must pass and which have to land before which. You read it, change what
71 you like, and put g1t on it with one click.
72 </p>
73 <Form method="post" className="mt-5 space-y-3">
74 <Textarea
75 name="brief"
76 rows={7}
77 required
78 placeholder={
79 "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."
80 }
81 />
82 <div className="flex flex-wrap items-center gap-3">
83 <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}>
84 <SubmitButton variant="accent" pending="Starting the planner…" disabled={!loaderData.can.run}>
85 <Sparkles size={15} />
86 Plan it
87 </SubmitButton>
88 </Hint>
89 <span className="text-xs text-muted">
90 Nothing is opened until you have read the plan.
91 </span>
92 </div>
93 {actionData?.error ? <ErrorText>{actionData.error}</ErrorText> : <ComputeNote note={loaderData.computeNote} />}
94 </Form>
95
96 <h3 className="mt-10 text-sm font-medium text-muted">Plans</h3>
97 <div className="mt-3">
98 {plans.length === 0 ? (
99 <EmptyState title="No plans yet" />
100 ) : (
101 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
102 {plans.map((plan) => (
103 <li key={plan.id}>
104 <Link
105 to={`${base}/plans/${plan.id}`}
106 className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-surface"
107 >
108 <span className="min-w-0 grow">
109 <span className="block truncate font-medium">{plan.brief}</span>
110 <span className="mt-0.5 block text-xs text-faint">
111 {plan.author.username} · <TimeAgo at={plan.createdAt} />
112 {plan.issues.length > 0 &&
113 ` · ${plan.issues.length} ${plan.issues.length === 1 ? "issue" : "issues"}`}
114 </span>
115 </span>
116 <span
117 className={`shrink-0 text-xs ${
118 plan.status === "failed"
119 ? "text-danger"
120 : plan.status === "ready"
121 ? "text-success"
122 : "text-muted"
123 }`}
124 >
125 {STATUS[plan.status]}
126 </span>
127 </Link>
128 </li>
129 ))}
130 </ul>
131 )}
132 </div>
133 </div>
134
135 <aside className="space-y-5 text-sm">
136 <section className="rounded-xl border border-line bg-surface p-5">
137 <h3 className="font-medium">What a plan gives you</h3>
138 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
139 <li>Issues small enough to be merged one at a time, written so that an agent needs nothing else.</li>
140 <li>What done looks like for each, in plain words, added to its description.</li>
141 <li>The files each will touch, and a dependency wherever two would collide.</li>
142 <li>
143 Independent issues are worked on at the same time. One that depends on
144 another starts when that has merged, from its result.
145 </li>
146 </ul>
147 </section>
148 </aside>
149 </div>
150 );
151}