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