g1t/apps/web/app/components/agents.tsx

495 lines19,665 bytesCodeBlame
1/**
2 * Agents at work, wherever they show: the project's Agents page, the
3 * workspace's fleet, a pull request's Agent panel and the pull request
4 * list. Stopping and messaging a run go through the project's
5 * `agents.json` resource route, so every place behaves the same.
6 */
7import { Bot, CircleSlash, Clock, Coins, Gauge, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react";
8import { type ReactNode, useEffect, useMemo, useState } from "react";
9import { Link, useFetcher } from "react-router";
10import { useRefreshWhile } from "../lib/refresh";
11
12import {
13 type AgentRun,
14 type AgentRunStatus,
15 type Confidence,
16 type RunKind,
17 RUN_KIND_LABEL,
18 type Stage,
19 isActiveRun,
20 isAgentKind,
21 takesMessages,
22} from "@g1t/contracts";
23
24import { RunAudit } from "./audit";
25import { STAGE_LABEL, StageDots } from "./lifecycle";
26import { Avatar, TimeAgo } from "./ui";
27import {
28 AlertDialog,
29 AlertDialogAction,
30 AlertDialogCancel,
31 AlertDialogContent,
32 AlertDialogDescription,
33 AlertDialogFooter,
34 AlertDialogHeader,
35 AlertDialogTitle,
36 AlertDialogTrigger,
37} from "./ui/alert-dialog";
38import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
39
40/** How often a page with something running asks again. */
41export const LIVE_MS = 4000;
42
43/** Revalidates the page every few seconds while `live`, as the merge queue does. */
44export function useLiveRefresh(live: boolean) {
45 useRefreshWhile(live, LIVE_MS);
46}
47
48export function formatCost(usd: number | null | undefined): string | null {
49 if (usd == null) return null;
50 if (usd > 0 && usd < 0.01) return "<$0.01";
51 return `$${usd.toFixed(2)}`;
52}
53
54function span(ms: number): string {
55 const seconds = Math.max(0, Math.floor(ms / 1000));
56 const hours = Math.floor(seconds / 3600);
57 const minutes = Math.floor((seconds % 3600) / 60);
58 if (hours > 0) return `${hours}h ${minutes}m`;
59 if (minutes > 0) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
60 return `${seconds}s`;
61}
62
63/** How long a run has gone on, ticking while it runs. */
64export function Elapsed({ from, to }: { from: string; to?: string | null }) {
65 const [now, setNow] = useState(() => Date.now());
66 useEffect(() => {
67 if (to) return;
68 const timer = setInterval(() => setNow(Date.now()), 1000);
69 return () => clearInterval(timer);
70 }, [to]);
71 const end = to ? new Date(to).getTime() : now;
72 return (
73 <span suppressHydrationWarning className="tabular-nums">
74 {span(end - new Date(from).getTime())}
75 </span>
76 );
77}
78
79const STATUS: Record<AgentRunStatus, { label: string; tone: string; icon: ReactNode }> = {
80 queued: { label: "Starting", tone: "text-muted border-line", icon: <Clock size={12} /> },
81 running: {
82 label: "Running",
83 tone: "text-merged border-merged/40 bg-merged/10",
84 icon: <Loader2 size={12} className="animate-spin" />,
85 },
86 succeeded: { label: "Done", tone: "text-accent border-accent/40 bg-accent/10", icon: <Bot size={12} /> },
87 failed: { label: "Failed", tone: "text-danger border-danger/40 bg-danger/10", icon: <TriangleAlert size={12} /> },
88 stopped: { label: "Stopped", tone: "text-warn border-warn/40 bg-warn/10", icon: <OctagonX size={12} /> },
89};
90
91export function RunStatusBadge({ status }: { status: AgentRunStatus }) {
92 const { label, tone, icon } = STATUS[status];
93 return (
94 <span className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${tone}`}>
95 {icon}
96 {label}
97 </span>
98 );
99}
100
101export function KindLabel({ kind }: { kind: RunKind }) {
102 return (
103 <span className="rounded border border-line px-1.5 py-px font-mono text-[0.6875rem] text-muted">
104 {RUN_KIND_LABEL[kind].toLowerCase()}
105 </span>
106 );
107}
108
109/** Where a project's agent actions are posted. */
110function actionUrl(run: AgentRun): string {
111 return `/${run.repo.namespace}/${run.repo.name}/agents.json`;
112}
113
114type ActionResult = { ok: boolean; notice?: string; error?: string } | undefined;
115
116/** Stop a run, after saying what that does. */
117export function StopRun({ run, compact }: { run: AgentRun; compact?: boolean }) {
118 const fetcher = useFetcher<ActionResult>();
119 const busy = fetcher.state !== "idle";
120 const onPull = run.number != null && isAgentKind(run.kind);
121 return (
122 <>
123 <AlertDialog>
124 <AlertDialogTrigger
125 disabled={busy}
126 className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-danger/50 hover:text-danger disabled:opacity-50"
127 >
128 <Square size={11} />
129 {busy ? "Stopping…" : compact ? "Stop" : "Stop run"}
130 </AlertDialogTrigger>
131 <AlertDialogContent>
132 <AlertDialogHeader>
133 <AlertDialogTitle>Stop this {RUN_KIND_LABEL[run.kind].toLowerCase()} run?</AlertDialogTitle>
134 <AlertDialogDescription>
135 Its sandbox is shut down at once and nothing more is pushed.
136 {onPull &&
137 ` g1t stops seeing #${run.number} through and leaves it for a person; what the agent already pushed stays on it.`}
138 </AlertDialogDescription>
139 </AlertDialogHeader>
140 <AlertDialogFooter>
141 <AlertDialogCancel>Keep it running</AlertDialogCancel>
142 <AlertDialogAction
143 onClick={() => fetcher.submit({ intent: "stop", run: run.id }, { method: "post", action: actionUrl(run) })}
144 >
145 Stop
146 </AlertDialogAction>
147 </AlertDialogFooter>
148 </AlertDialogContent>
149 </AlertDialog>
150 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
151 </>
152 );
153}
154
155/** Send the agent on a run's pull request a message. */
156export function MessageRun({ run }: { run: AgentRun }) {
157 const fetcher = useFetcher<ActionResult>();
158 const [open, setOpen] = useState(false);
159 const live = takesMessages(run.kind) && isActiveRun(run.status);
160 if (run.number == null) return null;
161 return (
162 <Dialog open={open} onOpenChange={setOpen}>
163 <DialogTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg">
164 <MessageSquare size={11} />
165 Message
166 </DialogTrigger>
167 <DialogContent>
168 <DialogHeader>
169 <DialogTitle>Message the agent on #{run.number}</DialogTitle>
170 <DialogDescription>
171 {live
172 ? "It reads this at its next step, between tool calls, without starting over."
173 : "This run does not read messages while it works. Your message waits on the pull request and is given to the agent's next run there."}
174 </DialogDescription>
175 </DialogHeader>
176 <fetcher.Form method="post" action={actionUrl(run)} className="space-y-3">
177 <input type="hidden" name="intent" value="message" />
178 <input type="hidden" name="number" value={run.number} />
179 <textarea
180 name="body"
181 required
182 rows={4}
183 autoComplete="off"
184 data-1p-ignore
185 placeholder="Keep the old flag working too…"
186 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
187 />
188 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
189 {fetcher.data?.ok && fetcher.data.notice && <p className="text-sm text-accent">{fetcher.data.notice}</p>}
190 <div className="flex justify-end">
191 <button
192 type="submit"
193 disabled={fetcher.state !== "idle"}
194 className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
195 >
196 {fetcher.state !== "idle" ? "Sending…" : "Send"}
197 </button>
198 </div>
199 </fetcher.Form>
200 </DialogContent>
201 </Dialog>
202 );
203}
204
205/** What a run is on, as a link: its pull request, or the plan it writes. */
206function RunTarget({ run, showRepo }: { run: AgentRun; showRepo?: boolean }) {
207 const base = `/${run.repo.namespace}/${run.repo.name}`;
208 const repo = showRepo ? (
209 <Link to={base} className="font-mono text-xs text-faint hover:text-muted">
210 {run.repo.namespace}/{run.repo.name}
211 </Link>
212 ) : null;
213 if (run.number != null) {
214 return (
215 <span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
216 {repo}
217 <Link to={`${base}/pull/${run.number}`} prefetch="intent" className="min-w-0 truncate font-medium hover:text-accent">
218 {run.title ?? "Pull request"} <span className="font-normal text-faint">#{run.number}</span>
219 </Link>
220 </span>
221 );
222 }
223 return (
224 <span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
225 {repo}
226 <Link to={`${base}/plans`} className="min-w-0 truncate font-medium hover:text-accent">
227 {run.title ?? "A plan"}
228 </Link>
229 </span>
230 );
231}
232
233/** One run, live or finished, with what can be done about it. */
234export function RunCard({ run, member, showRepo }: { run: AgentRun; member: boolean; showRepo?: boolean }) {
235 const base = `/${run.repo.namespace}/${run.repo.name}`;
236 const active = isActiveRun(run.status);
237 const cost = formatCost(run.costUsd);
238 return (
239 <li
240 className={`rounded-xl border bg-surface p-4 transition-colors hover:border-line-strong ${
241 active ? "border-merged/30" : "border-line"
242 }`}
243 >
244 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
245 <Avatar name={run.agent} size={22} />
246 <span className="text-sm font-medium">{run.agent}</span>
247 <KindLabel kind={run.kind} />
248 <span className="min-w-0 grow">
249 <RunTarget run={run} showRepo={showRepo} />
250 </span>
251 <RunStatusBadge status={run.status} />
252 </div>
253 {run.step && (
254 <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`} title={run.step}>
255 {active && <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
256 {run.step}
257 </p>
258 )}
259 {run.status === "failed" && run.error && <p className="mt-1.5 text-xs text-danger">{run.error}</p>}
260 <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
261 <span className="flex items-center gap-1">
262 <Clock size={12} />
263 {run.startedAt || active ? <Elapsed from={run.startedAt ?? run.createdAt} to={run.finishedAt} /> : "—"}
264 </span>
265 {cost && (
266 <span className="flex items-center gap-1" title={active ? "So far, as the harness reports it" : "As the harness reported it"}>
267 <Coins size={12} />
268 {cost}
269 {active && " so far"}
270 </span>
271 )}
272 {run.model && <span className="font-mono">{run.model}</span>}
273 {run.startedBy && <span>started by {run.startedBy}</span>}
274 <span>
275 <TimeAgo at={run.createdAt} />
276 </span>
277 <span className="grow" />
278 <Link to={`${base}/agents/runs/${run.id}`} className="hover:text-fg">
279 {run.stepCount} {run.stepCount === 1 ? "step" : "steps"}
280 </Link>
281 {run.number != null && (
282 <Link to={`${base}/sessions/${run.number}`} className="hover:text-fg">
283 Session
284 </Link>
285 )}
286 {member && active && <MessageRun run={run} />}
287 {member && active && <StopRun run={run} compact />}
288 </div>
289 </li>
290 );
291}
292
293/** The runs that are on now, newest first, then the rest. */
294export function splitRuns(runs: AgentRun[]): { live: AgentRun[]; done: AgentRun[] } {
295 return {
296 live: runs.filter((run) => isActiveRun(run.status)),
297 done: runs.filter((run) => !isActiveRun(run.status)),
298 };
299}
300
301type Live = { runs: AgentRun[]; member: boolean };
302
303/**
304 * The runs of a project, fetched from its `agents.json`, every few seconds
305 * while any is running. For pages whose own loader does not have them.
306 */
307export function useRuns(owner: string, repo: string, query: Record<string, string>): Live | null {
308 const fetcher = useFetcher<Live>();
309 const search = new URLSearchParams(query).toString();
310 const url = `/${owner}/${repo}/agents.json?${search}`;
311 const { load } = fetcher;
312 useEffect(() => {
313 load(url);
314 }, [load, url]);
315 const live = fetcher.data?.runs.some((run) => isActiveRun(run.status)) ?? false;
316 useEffect(() => {
317 if (!live) return;
318 const timer = setInterval(() => {
319 if (document.visibilityState === "visible") load(url);
320 }, LIVE_MS);
321 return () => clearInterval(timer);
322 }, [live, load, url]);
323 return fetcher.data ?? null;
324}
325
326/** "Agent confidence: Low — tests not added, 3 revisions", and what the agent said it was unsure of. */
327export function ConfidenceLine({ confidence }: { confidence: Confidence }) {
328 const tone = confidence.level === "low" ? "text-danger" : confidence.level === "medium" ? "text-warn" : "text-accent";
329 const level = { low: "Low", medium: "Medium", high: "High" }[confidence.level];
330 return (
331 <div className="mt-3 text-xs leading-5">
332 <p className="flex items-start gap-2">
333 <Gauge size={13} className={`mt-1 shrink-0 ${tone}`} />
334 <span className="min-w-0">
335 <span className="text-muted">Agent confidence: </span>
336 <span className={`font-medium ${tone}`}>{level}</span>
337 {confidence.reasons.length > 0 && <span className="text-fg-soft"> — {confidence.reasons.join(", ")}</span>}
338 </span>
339 </p>
340 {confidence.uncertainAbout.length > 0 && (
341 <p className="mt-0.5 pl-[1.3125rem] text-muted">Unsure about: {confidence.uncertainAbout.join("; ")}</p>
342 )}
343 </div>
344 );
345}
346
347/**
348 * The agent on a pull request, near the top of its page: who is working
349 * on it, at what stage, what it is doing this minute, for how long and at
350 * what cost, with its session, and Stop and Message for members.
351 */
352export function AgentPanel({
353 owner,
354 repo,
355 number,
356 stage,
357 confidence,
358}: {
359 owner: string;
360 repo: string;
361 number: number;
362 stage?: Stage | null;
363 /** How sure g1t is of the change, once the agent has finished it. */
364 confidence?: Confidence | null;
365}) {
366 const data = useRuns(owner, repo, { number: String(number), limit: "5" });
367 const runs = data?.runs ?? [];
368 const member = data?.member ?? false;
369 const current = runs.find((run) => isActiveRun(run.status)) ?? runs[0];
370 if (!current) return null;
371 const active = isActiveRun(current.status);
372 const base = `/${owner}/${repo}`;
373 const cost = formatCost(current.costUsd);
374 const spent = formatCost(runs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0) || null);
375 return (
376 <section
377 aria-label="Agent"
378 className={`mt-4 rounded-2xl bg-surface p-4 ring-1 ${active ? "ring-merged/40" : "ring-line"}`}
379 >
380 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
381 <Avatar name={current.agent} size={24} />
382 <p className="text-sm">
383 <span className="font-medium">{current.agent}</span>{" "}
384 {current.startedBy && current.startedBy !== current.agent && (
385 current.startedBy === "g1t" ? (
386 <span className="text-muted">
387 (started by <span className="text-fg">g1t</span>){" "}
388 </span>
389 ) : (
390 <span className="text-muted">
391 on behalf of <span className="text-fg">{current.startedBy}</span>{" "}
392 </span>
393 )
394 )}
395 <span className="text-muted">
396 {active
397 ? `is ${RUN_KIND_LABEL[current.kind].toLowerCase()}`
398 : `${current.status === "succeeded" ? "finished" : current.status} its ${RUN_KIND_LABEL[current.kind].toLowerCase()} run`}
399 </span>
400 </p>
401 {stage && (
402 <span className="flex items-center gap-2 text-xs text-muted">
403 <StageDots stage={stage} />
404 {STAGE_LABEL[stage]}
405 </span>
406 )}
407 <span className="grow" />
408 <RunStatusBadge status={current.status} />
409 </div>
410 {current.step && (
411 <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" title={current.step}>
412 {active && <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
413 {current.step}
414 </p>
415 )}
416 {confidence && <ConfidenceLine confidence={confidence} />}
417 <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
418 <span className="flex items-center gap-1">
419 <Clock size={12} />
420 <Elapsed from={current.startedAt ?? current.createdAt} to={current.finishedAt} />
421 </span>
422 {cost && (
423 <span className="flex items-center gap-1">
424 <Coins size={12} />
425 {cost}
426 {active ? " so far" : ""}
427 {runs.length > 1 && spent && ` · ${spent} over ${runs.length} runs`}
428 </span>
429 )}
430 {current.model && <span className="font-mono">{current.model}</span>}
431 <span className="grow" />
432 <Link to={`${base}/agents/runs/${current.id}`} className="hover:text-fg">
433 Run
434 </Link>
435 <Link to={`${base}/sessions/${number}`} className="hover:text-fg">
436 Session
437 </Link>
438 {member && active && <MessageRun run={current} />}
439 {member && active && <StopRun run={current} />}
440 </div>
441 {member && <RunAudit owner={owner} repo={repo} runIds={runs.map((run) => run.id)} live={active} />}
442 </section>
443 );
444}
445
446/** A small "agent working" mark for a row of the pull request list. */
447export function AgentBadge({ run }: { run: AgentRun | undefined }) {
448 if (!run) return null;
449 return (
450 <span
451 title={run.step ?? undefined}
452 className="inline-flex shrink-0 items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-xs text-merged"
453 >
454 <Loader2 size={11} className="animate-spin" />
455 {RUN_KIND_LABEL[run.kind]}
456 </span>
457 );
458}
459
460/** The active run on each pull request of a project, by number. */
461export function useActiveRuns(owner: string, repo: string): Map<number, AgentRun> {
462 const data = useRuns(owner, repo, { active: "1", limit: "100" });
463 return useMemo(() => {
464 const byNumber = new Map<number, AgentRun>();
465 for (const run of data?.runs ?? []) {
466 if (run.number != null && !byNumber.has(run.number)) byNumber.set(run.number, run);
467 }
468 return byNumber;
469 }, [data]);
470}
471
472/** Nothing running, said plainly. */
473export function Idle({ children }: { children: ReactNode }) {
474 return (
475 <p className="flex items-center gap-2 rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">
476 <CircleSlash size={15} className="text-faint" />
477 {children}
478 </p>
479 );
480}
481
482/**
483 * What the agent on a pull request is doing this minute, as one line, for
484 * an issue's sidebar: which agent picked the issue up, and where it is.
485 */
486export function AgentStepLine({ owner, repo, number }: { owner: string; repo: string; number: number }) {
487 const data = useRuns(owner, repo, { number: String(number), active: "1", limit: "1" });
488 const run = data?.runs[0];
489 if (!run?.step) return null;
490 return (
491 <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={run.step}>
492 {RUN_KIND_LABEL[run.kind]}: {run.step}
493 </span>
494 );
495}