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