g1t/apps/web/app/components/agents.tsx
| 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 | */ |
| 7 | import { Bot, CircleSlash, Clock, Coins, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react"; |
| 8 | import { type ReactNode, useEffect, useMemo, useState } from "react"; |
| 9 | import { Link, useFetcher, useRevalidator } from "react-router"; |
| 10 | |
| 11 | import { |
| 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 | |
| 22 | import { RunAudit } from "./audit"; |
| 23 | import { STAGE_LABEL, StageDots } from "./lifecycle"; |
| 24 | import { Avatar, TimeAgo } from "./ui"; |
| 25 | import { |
| 26 | AlertDialog, |
| 27 | AlertDialogAction, |
| 28 | AlertDialogCancel, |
| 29 | AlertDialogContent, |
| 30 | AlertDialogDescription, |
| 31 | AlertDialogFooter, |
| 32 | AlertDialogHeader, |
| 33 | AlertDialogTitle, |
| 34 | AlertDialogTrigger, |
| 35 | } from "./ui/alert-dialog"; |
| 36 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; |
| 37 | |
| 38 | /** How often a page with something running asks again. */ |
| 39 | export const LIVE_MS = 4000; |
| 40 | |
| 41 | /** Revalidates the page every few seconds while `live`, as the merge queue does. */ |
| 42 | export 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 | |
| 53 | export 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 | |
| 59 | function 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. */ |
| 69 | export 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 | |
| 84 | const 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 | |
| 96 | export 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 | |
| 106 | export 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. */ |
| 115 | function actionUrl(run: AgentRun): string { |
| 116 | return `/${run.repo.namespace}/${run.repo.name}/agents.json`; |
| 117 | } |
| 118 | |
| 119 | type ActionResult = { ok: boolean; notice?: string; error?: string } | undefined; |
| 120 | |
| 121 | /** Stop a run, after saying what that does. */ |
| 122 | export 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. */ |
| 161 | export 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. */ |
| 211 | function 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. */ |
| 239 | export 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. */ |
| 299 | export 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 | |
| 306 | type 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 | */ |
| 312 | export 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 | */ |
| 336 | export 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. */ |
| 421 | export 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. */ |
| 435 | export 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. */ |
| 447 | export 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 | */ |
| 460 | export 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 | } |