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

283 lines9,558 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import {
3 Bot,
4 ChevronRight,
5 GitCommitHorizontal,
6 Rocket,
7 StickyNote,
8 User,
9 Wrench,
10} from "lucide-react";
11import { useEffect } from "react";
12import { Form, Link, useRevalidator } from "react-router";
13
14import type { SessionEntry } from "@g1t/contracts";
15
16import type { Route } from "./+types/attempt";
17import {
18 Button,
19 CopyLine,
20 EmptyState,
21 ErrorText,
22 Status,
23 Textarea,
24 TimeAgo,
25} from "../../components/ui";
26import { repos } from "../../lib/services.server";
27import {
28 assertSameOrigin,
29 getViewer,
30 requireUser,
31 unwrap,
32} from "../../lib/session.server";
33
34const REFRESH_MS = 4000;
35
36export function meta({ loaderData }: Route.MetaArgs) {
37 return [
38 {
39 title: loaderData
40 ? `Attempt ${loaderData.attempt.number} · ${loaderData.intent.title} · g1t`
41 : "g1t",
42 },
43 ];
44}
45
46export async function loader({ params, context }: Route.LoaderArgs) {
47 const viewer = getViewer(context);
48 const [found, session] = await Promise.all([
49 env.WORK.getAttempt(params.id, viewer),
50 env.WORK.readSession(params.id, viewer),
51 ]);
52 const detail = unwrap(found);
53 const repo = await repos.getById(detail.attempt.repoId, viewer);
54 return {
55 ...detail,
56 session: unwrap(session),
57 viewer,
58 // Only the repository's owner can land an attempt.
59 canShip: repo.ok && repo.value.ownerId === viewer?.id,
60 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
61 };
62}
63
64export async function action({ request, params, context }: Route.ActionArgs) {
65 assertSameOrigin(request);
66 const user = requireUser(context, request);
67 const form = await request.formData();
68 const action = form.get("action");
69 const result =
70 action === "ship"
71 ? await env.WORK.shipAttempt(user, params.id)
72 : action === "abandon"
73 ? await env.WORK.abandonAttempt(user, params.id)
74 : await env.WORK.submitAttempt(
75 user,
76 params.id,
77 String(form.get("summary") ?? ""),
78 );
79 return result.ok ? null : { error: result.error.message };
80}
81
82/** One step of the session, on the timeline's rail. */
83function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
84 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
85 const Icon =
86 entry.kind === "prompt"
87 ? User
88 : entry.kind === "note"
89 ? StickyNote
90 : isTool
91 ? Wrench
92 : Bot;
93 return (
94 <li className="relative pl-10">
95 <span
96 className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
97 entry.kind === "prompt"
98 ? "border-accent/50 text-accent"
99 : "border-line text-faint"
100 }`}
101 >
102 <Icon size={14} />
103 </span>
104 {isTool ? (
105 <details className="group rounded-lg border border-line bg-surface">
106 <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
107 <ChevronRight
108 size={14}
109 className="text-faint transition-transform group-open:rotate-90"
110 />
111 <span className="font-mono text-xs text-accent">
112 {entry.tool ?? "tool"}
113 </span>
114 <span className="truncate font-mono text-xs text-muted">
115 {entry.kind === "tool_result" ? "→ " : ""}
116 {entry.text.split("\n")[0]}
117 </span>
118 </summary>
119 <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
120 {entry.text}
121 </pre>
122 </details>
123 ) : (
124 <div>
125 <p className="text-xs font-medium text-faint">
126 {entry.kind === "prompt"
127 ? "Prompt"
128 : entry.kind === "note"
129 ? "Note"
130 : agent}
131 </p>
132 <p
133 className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${
134 entry.kind === "prompt" ? "font-medium" : ""
135 }`}
136 >
137 {entry.text}
138 </p>
139 </div>
140 )}
141 {entry.commit && (
142 <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
143 <GitCommitHorizontal size={12} />
144 {entry.commit.slice(0, 7)}
145 </p>
146 )}
147 </li>
148 );
149}
150
151export default function AttemptPage({
152 loaderData,
153 actionData,
154 params,
155}: Route.ComponentProps) {
156 const { attempt, intent, session, viewer, canShip, defaultBranch } = loaderData;
157 const base = `/${params.owner}/${params.repo}`;
158 const remote = `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`;
159 const mine = viewer?.id === attempt.startedBy.id;
160 const active = attempt.status === "working" || attempt.status === "submitted";
161
162 // Follow a running attempt without a manual reload.
163 const revalidator = useRevalidator();
164 const working = attempt.status === "working";
165 useEffect(() => {
166 if (!working) return;
167 const timer = setInterval(() => {
168 if (document.visibilityState === "visible") revalidator.revalidate();
169 }, REFRESH_MS);
170 return () => clearInterval(timer);
171 }, [working, revalidator]);
172
173 return (
174 <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
175 <div className="min-w-0">
176 <p className="text-sm text-muted">
177 <Link
178 to={`${base}/intents/${intent.number}`}
179 className="hover:text-fg hover:underline"
180 >
181 {intent.title} <span className="text-faint">#{intent.number}</span>
182 </Link>
183 </p>
184 <div className="mt-1 flex flex-wrap items-center gap-3">
185 <h2 className="text-2xl font-semibold tracking-tight">
186 Attempt {attempt.number}
187 </h2>
188 <span className="flex items-center gap-1.5 font-mono text-sm text-muted">
189 <Bot size={15} />
190 {attempt.agent}
191 </span>
192 <Status value={attempt.status} />
193 </div>
194 <p className="mt-2 text-sm text-muted">
195 Started by{" "}
196 <span className="font-medium text-fg">{attempt.startedBy.username}</span>{" "}
197 <TimeAgo at={attempt.createdAt} />
198 </p>
199
200 {attempt.summary && (
201 <section className="mt-6 rounded-xl border border-line bg-surface p-5">
202 <h3 className="text-xs font-medium tracking-wide text-faint uppercase">
203 Summary
204 </h3>
205 <p className="mt-2 leading-relaxed whitespace-pre-wrap">
206 {attempt.summary}
207 </p>
208 </section>
209 )}
210
211 <h3 className="mt-10 font-semibold tracking-tight">Session</h3>
212 <div className="mt-4">
213 {session.length === 0 ? (
214 <EmptyState title="Nothing recorded yet">
215 The agent's prompts, reasoning and tool calls appear here as it
216 works.
217 </EmptyState>
218 ) : (
219 <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
220 {session.map((entry) => (
221 <Entry key={entry.seq} entry={entry} agent={attempt.agent} />
222 ))}
223 </ol>
224 )}
225 </div>
226 </div>
227
228 <aside className="space-y-6">
229 {canShip && active && intent.status === "open" && (
230 <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
231 <h3 className="text-sm font-medium">Ship this attempt</h3>
232 <p className="mt-1 text-xs text-muted">
233 Lands its commits on {defaultBranch} and closes the intent.
234 </p>
235 <Form method="post" className="mt-3 *:w-full">
236 <Button variant="accent" type="submit" name="action" value="ship">
237 <Rocket size={15} />
238 Ship to {defaultBranch}
239 </Button>
240 </Form>
241 {!(mine && active) && <ErrorText>{actionData?.error}</ErrorText>}
242 </section>
243 )}
244 <section>
245 <h3 className="text-sm font-medium">Working copy</h3>
246 <p className="mt-1 text-xs text-muted">
247 This fork belongs to the attempt. Pushes to it show up here.
248 </p>
249 <div className="mt-2">
250 <CopyLine text={`git clone ${remote}`} />
251 </div>
252 <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
253 <GitCommitHorizontal size={13} />
254 {attempt.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
255 </p>
256 </section>
257
258 {mine && active && (
259 <section className="rounded-xl border border-line bg-surface p-4">
260 <h3 className="text-sm font-medium">
261 {attempt.status === "submitted" ? "Update summary" : "Submit"}
262 </h3>
263 <Form method="post" className="mt-3 space-y-2">
264 <Textarea
265 name="summary"
266 rows={4}
267 placeholder="What changed and why"
268 defaultValue={attempt.summary ?? ""}
269 />
270 <div className="flex gap-2">
271 <Button type="submit">Submit attempt</Button>
272 <Button variant="quiet" type="submit" name="action" value="abandon">
273 Abandon
274 </Button>
275 </div>
276 </Form>
277 <ErrorText>{actionData?.error}</ErrorText>
278 </section>
279 )}
280 </aside>
281 </div>
282 );
283}