flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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