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

495 lines19,665 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1/**
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 */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step7import { Bot, CircleSlash, Clock, Coins, Gauge, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains8import { type ReactNode, useEffect, useMemo, useState } from "react";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending9import { Link, useFetcher } from "react-router";
10import { useRefreshWhile } from "../lib/refresh";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains11
12import {
13 type AgentRun,
14 type AgentRunStatus,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step15 type Confidence,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains16 type RunKind,
17 RUN_KIND_LABEL,
18 type Stage,
19 isActiveRun,
20 isAgentKind,
21 takesMessages,
22} from "@g1t/contracts";
23
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API24import { RunAudit } from "./audit";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains25import { 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) {
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending45 useRefreshWhile(live, LIVE_MS);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains46}
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
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step326/** "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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains347/**
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,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step357 confidence,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains358}: {
359 owner: string;
360 repo: string;
361 number: number;
362 stage?: Stage | null;
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step363 /** How sure g1t is of the change, once the agent has finished it. */
364 confidence?: Confidence | null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains365}) {
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>{" "}
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API384 {current.startedBy && current.startedBy !== current.agent && (
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily385 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 )
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API394 )}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains395 <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 )}
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step416 {confidence && <ConfidenceLine confidence={confidence} />}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains417 <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>
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API441 {member && <RunAudit owner={owner} repo={repo} runIds={runs.map((run) => run.id)} live={active} />}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains442 </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}