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

300 lines11,334 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Bot, GitCommitHorizontal, Play, Sparkles, Terminal } from "lucide-react";
3import { useEffect } from "react";
4import {
5 Form,
6 Link,
7 redirect,
8 useNavigation,
9 useRevalidator,
10} from "react-router";
11
12import type { Route } from "./+types/intent";
13import { Markdown } from "../../components/markdown";
14import {
15 Avatar,
16 Button,
17 CopyLine,
18 EmptyState,
19 ErrorText,
20 Input,
21 Status,
22 Textarea,
23 TimeAgo,
24} from "../../components/ui";
25import {
26 assertSameOrigin,
27 getViewer,
28 requireUser,
29 unwrap,
30} from "../../lib/session.server";
31
32const REFRESH_MS = 4000;
33
34export function meta({ loaderData, params }: Route.MetaArgs) {
35 const title = loaderData ? `${loaderData.intent.title} · ` : "";
36 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
37}
38
39export async function loader({ params, context }: Route.LoaderArgs) {
40 const viewer = getViewer(context);
41 const detail = unwrap(
42 await env.WORK.getIntent(
43 { namespace: params.owner, name: params.repo },
44 Number(params.number),
45 viewer,
46 ),
47 );
48 return {
49 ...detail,
50 viewer,
51 canRunHosted: await env.RUNNER.available(viewer),
52 };
53}
54
55export async function action({ request, params, context }: Route.ActionArgs) {
56 assertSameOrigin(request);
57 const user = requireUser(context, request);
58 const form = await request.formData();
59 const intentId = String(form.get("intentId") ?? "");
60
61 if (form.get("action") === "run-hosted") {
62 const result = await env.RUNNER.run(user, intentId, {
63 count: Number(form.get("count")),
64 instructions: String(form.get("instructions") ?? ""),
65 });
66 return result.ok ? null : { error: result.error.message };
67 }
68 if (form.get("action") === "withdraw") {
69 const result = await env.WORK.withdrawIntent(user, intentId);
70 return result.ok ? null : { error: result.error.message };
71 }
72 const result = await env.WORK.startAttempt(user, intentId, {
73 agent: String(form.get("agent") ?? ""),
74 runtime: "external",
75 });
76 if (!result.ok) return { error: result.error.message };
77 throw redirect(
78 `/${params.owner}/${params.repo}/attempts/${result.value.id}`,
79 );
80}
81
82export default function IntentPage({
83 loaderData,
84 actionData,
85 params,
86}: Route.ComponentProps) {
87 const { intent, attempts, viewer, canRunHosted } = loaderData;
88
89 // Follow running attempts without a manual reload.
90 const revalidator = useRevalidator();
91 const navigation = useNavigation();
92 const running = attempts.some((attempt) => attempt.status === "working");
93 useEffect(() => {
94 if (!running) return;
95 const timer = setInterval(() => {
96 if (document.visibilityState === "visible") revalidator.revalidate();
97 }, REFRESH_MS);
98 return () => clearInterval(timer);
99 }, [running, revalidator]);
100 const starting = navigation.formData?.get("action") === "run-hosted";
101 const base = `/${params.owner}/${params.repo}`;
102 const open = intent.status === "open";
103 return (
104 <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
105 <div className="min-w-0">
106 <div className="flex items-start gap-3">
107 <h2 className="grow text-2xl font-semibold tracking-tight text-balance">
108 {intent.title}{" "}
109 <span className="font-normal text-faint">#{intent.number}</span>
110 </h2>
111 <Status value={intent.status} />
112 </div>
113 <p className="mt-2 flex items-center gap-2 text-sm text-muted">
114 <Avatar name={intent.author.username} size={18} />
115 <span>
116 <span className="font-medium text-fg">{intent.author.username}</span>{" "}
117 opened this <TimeAgo at={intent.createdAt} />
118 </span>
119 </p>
120
121 {intent.brief && (
122 <div className="mt-6 rounded-xl border border-line bg-surface p-5">
123 <Markdown source={intent.brief} />
124 </div>
125 )}
126
127 <div className="mt-10 flex items-baseline justify-between">
128 <h3 className="font-semibold tracking-tight">Attempts</h3>
129 <p className="text-sm text-muted">
130 {attempts.length === 0
131 ? "None yet"
132 : `${attempts.length} in the arena`}
133 </p>
134 </div>
135 <div className="mt-3">
136 {attempts.length === 0 ? (
137 <EmptyState title="No agent has attempted this yet">
138 Start an attempt here, or point an agent at intent{" "}
139 <code className="font-mono">{intent.id}</code>.
140 </EmptyState>
141 ) : (
142 // Each attempt is a lane hanging off a shared rail.
143 <ol className="relative space-y-3 pl-6 before:absolute before:top-2 before:bottom-2 before:left-1.75 before:w-px before:bg-line">
144 {attempts.map((attempt) => (
145 <li key={attempt.id} className="relative">
146 <span
147 className={`absolute top-5 -left-6 size-3.75 rounded-full border-2 border-bg ${
148 attempt.status === "working"
149 ? "bg-accent"
150 : attempt.status === "submitted"
151 ? "bg-info"
152 : attempt.status === "shipped"
153 ? "bg-shipped"
154 : "bg-line-strong"
155 }`}
156 />
157 <Link
158 to={`${base}/attempts/${attempt.id}`}
159 className="block rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
160 >
161 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
162 <span className="flex items-center gap-1.5 font-mono text-sm font-medium">
163 <Bot size={15} className="text-faint" />
164 {attempt.agent}
165 </span>
166 {attempt.runtime === "hosted" && (
167 <span className="rounded-full border border-line px-1.5 py-px text-[0.6875rem] text-faint">
168 hosted
169 </span>
170 )}
171 <span className="text-sm text-faint">
172 attempt {attempt.number}
173 </span>
174 <Status value={attempt.status} />
175 <span className="ml-auto flex items-center gap-3 text-xs text-faint">
176 <span className="flex items-center gap-1 font-mono">
177 <GitCommitHorizontal size={13} />
178 {attempt.headCommit?.slice(0, 7) ?? "no commits"}
179 </span>
180 <TimeAgo at={attempt.updatedAt} />
181 </span>
182 </div>
183 {attempt.summary && (
184 <p className="mt-2.5 line-clamp-2 text-sm text-muted">
185 {attempt.summary}
186 </p>
187 )}
188 </Link>
189 </li>
190 ))}
191 </ol>
192 )}
193 </div>
194 </div>
195
196 <aside className="space-y-6">
197 {intent.checks.length > 0 && (
198 <section>
199 <h3 className="text-sm font-medium">Acceptance checks</h3>
200 <ul className="mt-2 space-y-1.5">
201 {intent.checks.map((check) => (
202 <li
203 key={check}
204 className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-1.5 font-mono text-xs"
205 >
206 <Terminal size={13} className="shrink-0 text-faint" />
207 <span className="truncate">{check}</span>
208 </li>
209 ))}
210 </ul>
211 </section>
212 )}
213
214 {open && canRunHosted && (
215 <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
216 <h3 className="flex items-center gap-2 text-sm font-medium">
217 <Sparkles size={15} className="text-accent" />
218 Run hosted agents
219 </h3>
220 <p className="mt-1 text-xs text-muted">
221 g1t starts Claude Code in a sandbox for each attempt. They work
222 in parallel, each in its own fork.
223 </p>
224 <Form method="post" className="mt-3 space-y-2">
225 <input type="hidden" name="intentId" value={intent.id} />
226 <input type="hidden" name="action" value="run-hosted" />
227 <label className="flex items-center justify-between gap-3 text-sm">
228 <span className="text-muted">Agents</span>
229 <select
230 name="count"
231 defaultValue="1"
232 className="rounded-md border border-line bg-bg px-2 py-1 text-sm"
233 >
234 {[1, 2, 3, 4, 5].map((count) => (
235 <option key={count} value={count}>
236 {count}
237 </option>
238 ))}
239 </select>
240 </label>
241 <Textarea
242 name="instructions"
243 rows={2}
244 placeholder="Extra guidance (optional)"
245 />
246 <div className="*:w-full">
247 <Button variant="accent" type="submit" disabled={starting}>
248 <Play size={14} />
249 {starting ? "Starting sandboxes…" : "Run"}
250 </Button>
251 </div>
252 </Form>
253 </section>
254 )}
255
256 {open && (
257 <section className="rounded-xl border border-line bg-surface p-4">
258 <h3 className="text-sm font-medium">Bring your own agent</h3>
259 <p className="mt-1 text-xs text-muted">
260 With g1t connected to your agent, give it this intent id.
261 </p>
262 <div className="mt-3">
263 <CopyLine text={intent.id} />
264 </div>
265 {viewer ? (
266 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
267 <input type="hidden" name="intentId" value={intent.id} />
268 <p className="text-xs text-muted">
269 Or start an attempt yourself and get a fork to push to.
270 </p>
271 <Input name="agent" placeholder="Agent name, e.g. claude-code" maxLength={60} />
272 <div className="*:w-full">
273 <Button type="submit">Start attempt</Button>
274 </div>
275 </Form>
276 ) : (
277 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
278 <Link to="/login" className="text-fg underline underline-offset-4">
279 Sign in
280 </Link>{" "}
281 to start an attempt.
282 </p>
283 )}
284 <ErrorText>{actionData?.error}</ErrorText>
285 </section>
286 )}
287
288 {open && viewer?.id === intent.author.id && (
289 <Form method="post">
290 <input type="hidden" name="intentId" value={intent.id} />
291 <input type="hidden" name="action" value="withdraw" />
292 <Button variant="quiet" type="submit">
293 Withdraw intent
294 </Button>
295 </Form>
296 )}
297 </aside>
298 </div>
299 );
300}