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

183 lines6,312 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Initial g1t: services, event bus, intents and attempts1import { env } from "cloudflare:workers";
2import { useEffect } from "react";
3import { Form, Link, useRevalidator } from "react-router";
4
5import type { SessionEntry } from "@g1t/contracts";
6
7import type { Route } from "./+types/attempt";
8import { Button, ErrorText, Status, Textarea, TimeAgo } from "../../components/ui";
9import {
10 assertSameOrigin,
11 getViewer,
12 requireUser,
13 unwrap,
14} from "../../lib/session.server";
15
16const REFRESH_MS = 4000;
17
18export function meta({ loaderData: data }: Route.MetaArgs) {
19 return [{ title: data ? `Attempt ${data.attempt.number} · ${data.intent.title} · g1t` : "g1t" }];
20}
21
22export async function loader({ params, context }: Route.LoaderArgs) {
23 const viewer = getViewer(context);
24 const [found, session] = await Promise.all([
25 env.WORK.getAttempt(params.id, viewer),
26 env.WORK.readSession(params.id, viewer),
27 ]);
28 return { ...unwrap(found), session: unwrap(session), viewer };
29}
30
31export async function action({ request, params, context }: Route.ActionArgs) {
32 assertSameOrigin(request);
33 const user = requireUser(context, request);
34 const form = await request.formData();
35 const result =
36 form.get("action") === "abandon"
37 ? await env.WORK.abandonAttempt(user, params.id)
38 : await env.WORK.submitAttempt(
39 user,
40 params.id,
41 String(form.get("summary") ?? ""),
42 );
43 return result.ok ? null : { error: result.error.message };
44}
45
46const KIND_LABEL: Record<SessionEntry["kind"], string> = {
47 prompt: "Prompt",
48 message: "Agent",
49 tool_call: "Tool",
50 tool_result: "Result",
51 note: "Note",
52};
53
54function Entry({ entry }: { entry: SessionEntry }) {
55 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
56 return (
57 <li className="grid grid-cols-[4.5rem_1fr] gap-3 px-4 py-3">
58 <span className="pt-0.5 text-xs text-muted">
59 {KIND_LABEL[entry.kind]}
60 </span>
61 <div className="min-w-0">
62 {entry.tool && (
63 <p className="font-mono text-xs text-accent">{entry.tool}</p>
64 )}
65 <p
66 className={`whitespace-pre-wrap break-words text-sm ${
67 isTool ? "font-mono text-muted" : ""
68 } ${entry.kind === "prompt" ? "font-medium" : ""}`}
69 >
70 {entry.text}
71 </p>
72 {entry.commit && (
73 <p className="mt-1 font-mono text-xs text-muted">
74 at {entry.commit.slice(0, 7)}
75 </p>
76 )}
77 </div>
78 </li>
79 );
80}
81
82export default function AttemptPage({
83 loaderData,
84 actionData,
85 params,
86}: Route.ComponentProps) {
87 const { attempt, intent, session, viewer } = loaderData;
88 const base = `/${params.owner}/${params.repo}`;
89 const remote = `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`;
90 const mine = viewer?.id === attempt.startedBy.id;
91 const active = attempt.status === "working" || attempt.status === "submitted";
92
93 // Follow a running attempt without a manual reload.
94 const revalidator = useRevalidator();
95 const working = attempt.status === "working";
96 useEffect(() => {
97 if (!working) return;
98 const timer = setInterval(() => {
99 if (document.visibilityState === "visible") revalidator.revalidate();
100 }, REFRESH_MS);
101 return () => clearInterval(timer);
102 }, [working, revalidator]);
103
104 return (
105 <div className="mt-6 grid gap-8 lg:grid-cols-[1fr_20rem]">
106 <div className="min-w-0">
107 <p className="text-sm text-muted">
108 <Link to={`${base}/intents/${intent.number}`} className="hover:text-fg">
109 {intent.title} #{intent.number}
110 </Link>
111 </p>
112 <div className="mt-1 flex items-center gap-3">
113 <h2 className="text-2xl font-semibold tracking-tight">
114 Attempt {attempt.number}
115 </h2>
116 <span className="font-mono text-sm text-muted">{attempt.agent}</span>
117 <Status value={attempt.status} />
118 </div>
119 <p className="mt-1 text-sm text-muted">
120 Started by {attempt.startedBy.username}{" "}
121 <TimeAgo at={attempt.createdAt} /> · head{" "}
122 <span className="font-mono">
123 {attempt.headCommit?.slice(0, 7) ?? "none yet"}
124 </span>
125 </p>
126
127 {attempt.summary && (
128 <p className="mt-6 whitespace-pre-wrap rounded-md border border-line bg-surface p-4 text-sm leading-relaxed">
129 {attempt.summary}
130 </p>
131 )}
132
133 <h3 className="mt-10 text-sm font-medium text-muted">Session</h3>
134 {session.length === 0 ? (
135 <p className="mt-3 rounded-md border border-dashed border-line p-6 text-center text-sm text-muted">
136 Nothing recorded yet. The agent's prompts, messages and tool calls
137 appear here as it works.
138 </p>
139 ) : (
140 <ol className="mt-3 divide-y divide-line rounded-md border border-line">
141 {session.map((entry) => (
142 <Entry key={entry.seq} entry={entry} />
143 ))}
144 </ol>
145 )}
146 </div>
147
148 <aside className="space-y-6">
149 <section>
150 <h3 className="text-sm font-medium text-muted">Working copy</h3>
151 <pre className="mt-2 overflow-x-auto rounded-md border border-line bg-surface p-3 font-mono text-xs">
152 git clone {remote}
153 </pre>
154 <p className="mt-2 text-xs text-muted">
155 This fork belongs to the attempt. Pushes to it show up here.
156 </p>
157 </section>
158 {mine && active && (
159 <section>
160 <h3 className="text-sm font-medium text-muted">
161 {attempt.status === "submitted" ? "Update summary" : "Submit"}
162 </h3>
163 <Form method="post" className="mt-2 space-y-2">
164 <Textarea
165 name="summary"
166 rows={4}
167 placeholder="What changed and why"
168 defaultValue={attempt.summary ?? ""}
169 />
170 <div className="flex gap-2">
171 <Button type="submit">Submit attempt</Button>
172 <Button variant="quiet" type="submit" name="action" value="abandon">
173 Abandon
174 </Button>
175 </div>
176 </Form>
177 <ErrorText>{actionData?.error}</ErrorText>
178 </section>
179 )}
180 </aside>
181 </div>
182 );
183}