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

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