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

219 lines8,304 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Bot, GitCommitHorizontal, Terminal } from "lucide-react";
3import { Form, Link, redirect } from "react-router";
4
5import type { Route } from "./+types/intent";
6import { Markdown } from "../../components/markdown";
7import {
8 Avatar,
9 Button,
10 CopyLine,
11 EmptyState,
12 ErrorText,
13 Input,
14 Status,
15 TimeAgo,
16} from "../../components/ui";
17import {
18 assertSameOrigin,
19 getViewer,
20 requireUser,
21 unwrap,
22} from "../../lib/session.server";
23
24export function meta({ loaderData, params }: Route.MetaArgs) {
25 const title = loaderData ? `${loaderData.intent.title} · ` : "";
26 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
27}
28
29export async function loader({ params, context }: Route.LoaderArgs) {
30 const viewer = getViewer(context);
31 const detail = unwrap(
32 await env.WORK.getIntent(
33 { namespace: params.owner, name: params.repo },
34 Number(params.number),
35 viewer,
36 ),
37 );
38 return { ...detail, viewer };
39}
40
41export async function action({ request, params, context }: Route.ActionArgs) {
42 assertSameOrigin(request);
43 const user = requireUser(context, request);
44 const form = await request.formData();
45 const intentId = String(form.get("intentId") ?? "");
46
47 if (form.get("action") === "withdraw") {
48 const result = await env.WORK.withdrawIntent(user, intentId);
49 return result.ok ? null : { error: result.error.message };
50 }
51 const result = await env.WORK.startAttempt(user, intentId, {
52 agent: String(form.get("agent") ?? ""),
53 runtime: "external",
54 });
55 if (!result.ok) return { error: result.error.message };
56 throw redirect(
57 `/${params.owner}/${params.repo}/attempts/${result.value.id}`,
58 );
59}
60
61export default function IntentPage({
62 loaderData,
63 actionData,
64 params,
65}: Route.ComponentProps) {
66 const { intent, attempts, viewer } = loaderData;
67 const base = `/${params.owner}/${params.repo}`;
68 const open = intent.status === "open";
69 return (
70 <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
71 <div className="min-w-0">
72 <div className="flex items-start gap-3">
73 <h2 className="grow text-2xl font-semibold tracking-tight text-balance">
74 {intent.title}{" "}
75 <span className="font-normal text-faint">#{intent.number}</span>
76 </h2>
77 <Status value={intent.status} />
78 </div>
79 <p className="mt-2 flex items-center gap-2 text-sm text-muted">
80 <Avatar name={intent.author.username} size={18} />
81 <span>
82 <span className="font-medium text-fg">{intent.author.username}</span>{" "}
83 opened this <TimeAgo at={intent.createdAt} />
84 </span>
85 </p>
86
87 {intent.brief && (
88 <div className="mt-6 rounded-xl border border-line bg-surface p-5">
89 <Markdown source={intent.brief} />
90 </div>
91 )}
92
93 <div className="mt-10 flex items-baseline justify-between">
94 <h3 className="font-semibold tracking-tight">Attempts</h3>
95 <p className="text-sm text-muted">
96 {attempts.length === 0
97 ? "None yet"
98 : `${attempts.length} in the arena`}
99 </p>
100 </div>
101 <div className="mt-3">
102 {attempts.length === 0 ? (
103 <EmptyState title="No agent has attempted this yet">
104 Start an attempt here, or point an agent at intent{" "}
105 <code className="font-mono">{intent.id}</code>.
106 </EmptyState>
107 ) : (
108 // Each attempt is a lane hanging off a shared rail.
109 <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">
110 {attempts.map((attempt) => (
111 <li key={attempt.id} className="relative">
112 <span
113 className={`absolute top-5 -left-6 size-3.75 rounded-full border-2 border-bg ${
114 attempt.status === "working"
115 ? "bg-accent"
116 : attempt.status === "submitted"
117 ? "bg-info"
118 : attempt.status === "shipped"
119 ? "bg-shipped"
120 : "bg-line-strong"
121 }`}
122 />
123 <Link
124 to={`${base}/attempts/${attempt.id}`}
125 className="block rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
126 >
127 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
128 <span className="flex items-center gap-1.5 font-mono text-sm font-medium">
129 <Bot size={15} className="text-faint" />
130 {attempt.agent}
131 </span>
132 <span className="text-sm text-faint">
133 attempt {attempt.number}
134 </span>
135 <Status value={attempt.status} />
136 <span className="ml-auto flex items-center gap-3 text-xs text-faint">
137 <span className="flex items-center gap-1 font-mono">
138 <GitCommitHorizontal size={13} />
139 {attempt.headCommit?.slice(0, 7) ?? "no commits"}
140 </span>
141 <TimeAgo at={attempt.updatedAt} />
142 </span>
143 </div>
144 {attempt.summary && (
145 <p className="mt-2.5 line-clamp-2 text-sm text-muted">
146 {attempt.summary}
147 </p>
148 )}
149 </Link>
150 </li>
151 ))}
152 </ol>
153 )}
154 </div>
155 </div>
156
157 <aside className="space-y-6">
158 {intent.checks.length > 0 && (
159 <section>
160 <h3 className="text-sm font-medium">Acceptance checks</h3>
161 <ul className="mt-2 space-y-1.5">
162 {intent.checks.map((check) => (
163 <li
164 key={check}
165 className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-1.5 font-mono text-xs"
166 >
167 <Terminal size={13} className="shrink-0 text-faint" />
168 <span className="truncate">{check}</span>
169 </li>
170 ))}
171 </ul>
172 </section>
173 )}
174
175 {open && (
176 <section className="rounded-xl border border-line bg-surface p-4">
177 <h3 className="text-sm font-medium">Put an agent on it</h3>
178 <p className="mt-1 text-xs text-muted">
179 With g1t connected to your agent, give it this intent id.
180 </p>
181 <div className="mt-3">
182 <CopyLine text={intent.id} />
183 </div>
184 {viewer ? (
185 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
186 <input type="hidden" name="intentId" value={intent.id} />
187 <p className="text-xs text-muted">
188 Or start an attempt yourself and get a fork to push to.
189 </p>
190 <Input name="agent" placeholder="Agent name, e.g. claude-code" maxLength={60} />
191 <div className="*:w-full">
192 <Button type="submit">Start attempt</Button>
193 </div>
194 </Form>
195 ) : (
196 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
197 <Link to="/login" className="text-fg underline underline-offset-4">
198 Sign in
199 </Link>{" "}
200 to start an attempt.
201 </p>
202 )}
203 <ErrorText>{actionData?.error}</ErrorText>
204 </section>
205 )}
206
207 {open && viewer?.id === intent.author.id && (
208 <Form method="post">
209 <input type="hidden" name="intentId" value={intent.id} />
210 <input type="hidden" name="action" value="withdraw" />
211 <Button variant="quiet" type="submit">
212 Withdraw intent
213 </Button>
214 </Form>
215 )}
216 </aside>
217 </div>
218 );
219}