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

164 lines5,811 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Form, Link, redirect } from "react-router";
3
4import type { Route } from "./+types/intent";
5import { Button, ErrorText, Input, Status, TimeAgo } from "../../components/ui";
6import {
7 assertSameOrigin,
8 getViewer,
9 requireUser,
10 unwrap,
11} from "../../lib/session.server";
12
13export function meta({ loaderData: data, params }: Route.MetaArgs) {
14 const title = data ? `${data.intent.title} · ` : "";
15 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
16}
17
18export async function loader({ params, context }: Route.LoaderArgs) {
19 const viewer = getViewer(context);
20 const detail = unwrap(
21 await env.WORK.getIntent(
22 { namespace: params.owner, name: params.repo },
23 Number(params.number),
24 viewer,
25 ),
26 );
27 return { ...detail, viewer };
28}
29
30export async function action({ request, params, context }: Route.ActionArgs) {
31 assertSameOrigin(request);
32 const user = requireUser(context, request);
33 const form = await request.formData();
34 const intentId = String(form.get("intentId") ?? "");
35 const base = `/${params.owner}/${params.repo}`;
36
37 if (form.get("action") === "withdraw") {
38 const result = await env.WORK.withdrawIntent(user, intentId);
39 return result.ok ? null : { error: result.error.message };
40 }
41 const result = await env.WORK.startAttempt(user, intentId, {
42 agent: String(form.get("agent") ?? ""),
43 runtime: "external",
44 });
45 if (!result.ok) return { error: result.error.message };
46 throw redirect(`${base}/attempts/${result.value.id}`);
47}
48
49export default function IntentPage({
50 loaderData,
51 actionData,
52 params,
53}: Route.ComponentProps) {
54 const { intent, attempts, viewer } = loaderData;
55 const base = `/${params.owner}/${params.repo}`;
56 const open = intent.status === "open";
57 return (
58 <div className="mt-6 grid gap-8 lg:grid-cols-[1fr_20rem]">
59 <div className="min-w-0">
60 <div className="flex items-start gap-3">
61 <h2 className="grow text-2xl font-semibold tracking-tight">
62 {intent.title}{" "}
63 <span className="font-mono font-normal text-muted">
64 #{intent.number}
65 </span>
66 </h2>
67 <Status value={intent.status} />
68 </div>
69 <p className="mt-1 text-sm text-muted">
70 Opened by {intent.author.username} <TimeAgo at={intent.createdAt} />
71 </p>
72
73 {intent.brief && (
74 <p className="mt-6 whitespace-pre-wrap leading-relaxed">
75 {intent.brief}
76 </p>
77 )}
78
79 <h3 className="mt-10 text-sm font-medium text-muted">
80 Attempts ({attempts.length})
81 </h3>
82 {attempts.length === 0 ? (
83 <p className="mt-3 rounded-md border border-dashed border-line p-6 text-center text-sm text-muted">
84 No agent has attempted this yet.
85 </p>
86 ) : (
87 <ul className="mt-3 space-y-2">
88 {attempts.map((attempt) => (
89 <li key={attempt.id}>
90 <Link
91 to={`${base}/attempts/${attempt.id}`}
92 className="block rounded-md border border-line px-4 py-3 hover:border-muted"
93 >
94 <div className="flex items-center gap-3">
95 <span className="font-mono text-sm text-muted">
96 {attempt.number}
97 </span>
98 <span className="font-mono text-sm">{attempt.agent}</span>
99 <Status value={attempt.status} />
100 <span className="ml-auto font-mono text-xs text-muted">
101 {attempt.headCommit?.slice(0, 7) ?? "no commits"} ·{" "}
102 <TimeAgo at={attempt.updatedAt} />
103 </span>
104 </div>
105 {attempt.summary && (
106 <p className="mt-2 line-clamp-2 text-sm text-muted">
107 {attempt.summary}
108 </p>
109 )}
110 </Link>
111 </li>
112 ))}
113 </ul>
114 )}
115 </div>
116
117 <aside className="space-y-6">
118 {intent.checks.length > 0 && (
119 <section>
120 <h3 className="text-sm font-medium text-muted">Acceptance checks</h3>
121 <ul className="mt-2 space-y-1 font-mono text-sm">
122 {intent.checks.map((check) => (
123 <li key={check} className="rounded bg-surface px-2 py-1">
124 {check}
125 </li>
126 ))}
127 </ul>
128 </section>
129 )}
130 {open && viewer && (
131 <section>
132 <h3 className="text-sm font-medium text-muted">Start an attempt</h3>
133 <Form method="post" className="mt-2 space-y-2">
134 <input type="hidden" name="intentId" value={intent.id} />
135 <Input name="agent" placeholder="claude-code" maxLength={60} />
136 <Button type="submit">Start attempt</Button>
137 </Form>
138 <p className="mt-2 text-xs text-muted">
139 Creates a fork for the agent to push to.
140 </p>
141 </section>
142 )}
143 {open && !viewer && (
144 <p className="text-sm text-muted">
145 <Link to="/login" className="text-fg underline">
146 Sign in
147 </Link>{" "}
148 to start an attempt.
149 </p>
150 )}
151 {open && viewer?.id === intent.author.id && (
152 <Form method="post">
153 <input type="hidden" name="intentId" value={intent.id} />
154 <input type="hidden" name="action" value="withdraw" />
155 <Button variant="quiet" type="submit">
156 Withdraw intent
157 </Button>
158 </Form>
159 )}
160 <ErrorText>{actionData?.error}</ErrorText>
161 </aside>
162 </div>
163 );
164}