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

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