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

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