Skip to content

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

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