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

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