Skip to content

g1t/apps/web/app/routes/repo/actions.tsx

455 lines19,835 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.

Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1import { AlertTriangle, BadgeCheck, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
Merge branch 'worktree-agent-a985c01f43538af01'2import { useEffect, useRef, useState } from "react";
3import { Form, Link, useSearchParams } from "react-router";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs4
5import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts";
6
7import type { Route } from "./+types/actions";
Social cards for every page: og.g1t.sh8import { page } from "../../lib/meta";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs9import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
Fast pages, required checks on the branch, self-hosted runners, honest incidents10import { AddCiPrompt } from "../../components/add-ci";
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)11import { Button, ComputeNote, CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
Workspace names and icons, and a component kit for every control12import { CheckboxOption } from "../../components/ui/checkbox";
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)13import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog";
14import { useAddresses } from "../../lib/addresses";
15import { badgeMarkdown, badgeUrl } from "../../lib/badge";
Workspace names and icons, and a component kit for every control16import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look17import { computeNoteFor } from "../../lib/compute.server";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs18import { actions } from "../../lib/services.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look19import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
20import { accessFor, refusal, repoFor } from "../../lib/access.server";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending21import { useRefreshWhile } from "../../lib/refresh";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs22
Social cards for every page: og.g1t.sh23export function meta({ params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` });
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs25}
26
27export async function loader({ params, context, request }: Route.LoaderArgs) {
28 const viewer = getViewer(context);
29 const repo = { namespace: params.owner, name: params.repo };
30 const selected = new URL(request.url).searchParams.get("workflow") ?? undefined;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look31 const found = await repoFor(context, params);
32 const can = found.ok ? accessFor(viewer, found.value).can : null;
33 // Starting runs needs Write; turning a workflow on or off, Maintain.
34 const member = can?.run ?? false;
Fast pages, required checks on the branch, self-hosted runners, honest incidents35 // Who may open the pull request that adds a starter workflow.
36 const canPush = can?.push ?? false;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look37 const [workflows, runs, computeNote] = await Promise.all([
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs38 actions.workflows(repo, viewer),
39 actions.runs(repo, viewer, { workflow: selected, limit: 50 }),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look40 // Jobs run on g1t's machines as the workspace's plan allows, or from
41 // the open-source pool on a public repository; members are told
42 // before a run is refused for it.
43 member && found.ok
44 ? computeNoteFor(params.owner, "workflow", !found.value.isPrivate).catch(() => null)
45 : Promise.resolve(null),
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs46 ]);
47 return {
48 workflows: unwrap(workflows),
49 runs: runs.ok ? runs.value : [],
New workspaces are told to connect a model, before anything fails50 member,
Fast pages, required checks on the branch, self-hosted runners, honest incidents51 canPush,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look52 manage: can?.manage_settings ?? false,
53 computeNote,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs54 selected: selected ?? null,
55 };
56}
57
58export async function action({ request, params, context }: Route.ActionArgs) {
59 assertSameOrigin(request);
60 const user = requireUser(context, request);
61 const repo = { namespace: params.owner, name: params.repo };
62 const form = await request.formData();
63 const workflow = String(form.get("workflow") ?? "");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look64 const refused = await refusal(context, params, form.get("intent") === "toggle" ? "manage_settings" : "run");
65 if (refused) return { error: refused };
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs66 if (form.get("intent") === "toggle") {
67 const changed = await actions.setWorkflowEnabled(user, repo, workflow, form.get("enabled") === "true");
68 return changed.ok ? {} : { error: changed.error.message };
69 }
70 const inputs: Record<string, unknown> = {};
71 for (const [key, value] of form.entries()) {
72 if (key.startsWith("input.")) inputs[key.slice(6)] = value === "on" ? true : value;
73 }
74 // An unticked checkbox sends nothing: it is false.
75 for (const key of String(form.get("booleans") ?? "").split(",").filter(Boolean)) {
76 if (!(key in inputs)) inputs[key] = false;
77 }
78 const ref = String(form.get("ref") ?? "").trim() || undefined;
79 const started = await actions.dispatch(user, repo, workflow, ref, inputs);
80 return started.ok ? { started: started.value } : { error: started.error.message };
81}
82
83/** Re-reads the page every few seconds while something is still running. */
84function useLiveWhile(running: boolean) {
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending85 useRefreshWhile(running, 3000);
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs86}
87
88const EVENT_WORDS: Record<string, string> = {
89 push: "push",
90 pull_request: "pull request",
91 pull_request_target: "pull request (target)",
92 pull_request_review: "review",
93 issues: "issue",
94 issue_comment: "comment",
95 schedule: "schedule",
96 workflow_dispatch: "manual",
97};
98
99function RunRow({ run, base, showWorkflow }: { run: WorkflowRun; base: string; showWorkflow: boolean }) {
100 return (
101 <li className="border-t border-line first:border-t-0">
102 <Link to={`${base}/actions/runs/${run.id}`} className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
103 <span className="mt-0.5">
104 <StatusIcon status={run.status} conclusion={run.conclusion} />
105 </span>
106 <span className="min-w-0 grow">
107 <span className="block truncate text-sm font-medium">{run.title || run.name}</span>
108 <span className="mt-0.5 block truncate text-xs text-muted">
109 {showWorkflow && <span className="text-fg/80">{run.name}</span>}
110 {showWorkflow && " · "}#{run.number}
111 {run.attempt > 1 && ` (attempt ${run.attempt})`}
112 {" · "}
113 {EVENT_WORDS[run.event] ?? run.event}
114 {run.actor && ` by ${run.actor}`}
115 </span>
116 </span>
117 <span className="hidden shrink-0 items-center gap-1 rounded-md bg-raised px-1.5 py-0.5 font-mono text-xs text-muted sm:inline-flex">
118 <GitBranch size={11} />
119 {shortRef(run.ref)}
120 </span>
121 <span className="w-28 shrink-0 text-right text-xs text-faint">
122 <TimeAgo at={run.createdAt} />
123 {run.startedAt && <span className="block font-mono">{duration(run.startedAt, run.finishedAt)}</span>}
124 </span>
125 </Link>
126 </li>
127 );
128}
129
130function InputField({ name, spec }: { name: string; spec: DispatchInput }) {
131 const label = (
132 <span className="mb-1 block text-xs font-medium text-muted">
133 {spec.description || name}
134 {spec.required && <span className="text-danger"> *</span>}
135 </span>
136 );
137 const control = "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-accent-dim";
138 if (spec.type === "boolean") {
139 return (
Workspace names and icons, and a component kit for every control140 <CheckboxOption
141 name={`input.${name}`}
142 defaultChecked={spec.default === true || spec.default === "true"}
143 label={spec.description || name}
144 />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs145 );
146 }
147 if (spec.type === "choice" && spec.options) {
148 return (
Workspace names and icons, and a component kit for every control149 <div>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs150 {label}
Workspace names and icons, and a component kit for every control151 <Select name={`input.${name}`} defaultValue={String(spec.default ?? spec.options[0] ?? "") || undefined}>
152 <SelectTrigger size="sm" aria-label={spec.description || name}>
153 <SelectValue placeholder="Choose" />
154 </SelectTrigger>
155 <SelectContent>
156 {/* A Radix item cannot have the value "". */}
157 {spec.options.filter((option) => option !== "").map((option) => (
158 <SelectItem key={option} value={option}>
159 {option}
160 </SelectItem>
161 ))}
162 </SelectContent>
163 </Select>
164 </div>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs165 );
166 }
167 return (
168 <label className="block">
169 {label}
170 <input
171 name={`input.${name}`}
172 required={spec.required}
173 defaultValue={spec.default == null ? "" : String(spec.default)}
174 type={spec.type === "number" ? "number" : "text"}
175 className={control}
176 autoComplete="off"
177 />
178 </label>
179 );
180}
181
182function RunWorkflow({ workflow }: { workflow: Workflow }) {
183 const [open, setOpen] = useState(false);
Merge branch 'worktree-agent-a985c01f43538af01'184 // Open, saying it is starting, until the run shows below or the error does.
185 const running = usePending({ intent: "dispatch", workflow: workflow.id });
186 const was = useRef(false);
187 useEffect(() => {
188 if (was.current && !running) setOpen(false);
189 was.current = running;
190 }, [running]);
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs191 const inputs = Object.entries(workflow.dispatch ?? {});
192 const booleans = inputs.filter(([, spec]) => spec.type === "boolean").map(([name]) => name);
193 return (
194 <div className="relative">
195 <Button type="button" variant="quiet" onClick={() => setOpen((v) => !v)}>
196 <Play size={14} />
197 Run workflow
198 </Button>
199 {open && (
200 <Form
201 method="post"
202 className="absolute right-0 z-20 mt-2 w-80 space-y-3 rounded-xl border border-line bg-surface p-4 shadow-xl"
203 >
Merge branch 'worktree-agent-a985c01f43538af01'204 <input type="hidden" name="intent" value="dispatch" />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs205 <input type="hidden" name="workflow" value={workflow.id} />
206 <input type="hidden" name="booleans" value={booleans.join(",")} />
207 <label className="block">
208 <span className="mb-1 block text-xs font-medium text-muted">Branch or tag</span>
209 <input
210 name="ref"
211 placeholder="The default branch"
212 className="w-full rounded-md border border-line bg-bg px-2.5 py-1.5 font-mono text-sm outline-none focus:border-accent-dim"
213 autoComplete="off"
214 />
215 </label>
216 {inputs.map(([name, spec]) => (
217 <InputField key={name} name={name} spec={spec} />
218 ))}
Merge branch 'worktree-agent-a985c01f43538af01'219 <SubmitButton match={{ intent: "dispatch", workflow: workflow.id }} pending="Starting…">
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs220 <PlayCircle size={14} />
221 Run
Merge branch 'worktree-agent-a985c01f43538af01'222 </SubmitButton>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs223 </Form>
224 )}
225 </div>
226 );
227}
228
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)229/** The address of a status badge for the workflow, and Markdown to show it, for a branch and event if chosen. */
230function StatusBadge({ workflow, repo }: { workflow: Workflow; repo: string }) {
231 const { site } = useAddresses();
232 const [branch, setBranch] = useState("");
233 const [event, setEvent] = useState("");
234 const file = workflow.path.split("/").pop() ?? workflow.path;
235 const options = { branch: branch.trim() || undefined, event: event || undefined };
236 const control = "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-accent-dim";
237 return (
238 <Dialog>
239 <DialogTrigger asChild>
240 <Button type="button" variant="quiet">
241 <BadgeCheck size={14} />
242 Create status badge
243 </Button>
244 </DialogTrigger>
245 <DialogContent>
246 <DialogHeader>
247 <DialogTitle>Create status badge</DialogTitle>
248 <DialogDescription>
249 A badge that shows how {workflow.name}'s latest finished run went. Paste it into a README.
250 </DialogDescription>
251 </DialogHeader>
252 <div className="grid gap-3 sm:grid-cols-2">
253 <label className="block">
254 <span className="mb-1 block text-xs font-medium text-muted">Branch</span>
255 <input
256 value={branch}
257 onChange={(e) => setBranch(e.target.value)}
258 placeholder="The default branch"
259 className={`${control} font-mono`}
260 autoComplete="off"
261 />
262 </label>
263 <div>
264 <span className="mb-1 block text-xs font-medium text-muted">Event</span>
265 <Select value={event || "any"} onValueChange={(value) => setEvent(value === "any" ? "" : value)}>
266 <SelectTrigger size="sm" aria-label="Event">
267 <SelectValue />
268 </SelectTrigger>
269 <SelectContent>
270 <SelectItem value="any">Any event</SelectItem>
271 {workflow.events.map((name) => (
272 <SelectItem key={name} value={name}>
273 {name}
274 </SelectItem>
275 ))}
276 </SelectContent>
277 </Select>
278 </div>
279 </div>
280 <div className="flex min-h-10 items-center rounded-lg border border-line bg-bg px-3 py-2">
281 <img src={badgeUrl(site, repo, file, options)} alt={`${workflow.name} status`} height={20} />
282 </div>
283 <CopyLine text={badgeMarkdown(site, repo, { name: workflow.name, file }, options)} />
284 <p className="text-xs text-muted">
285 Anyone can see a public repository's badge. A private repository's shows only to people who can see the repository.
286 </p>
287 </DialogContent>
288 </Dialog>
289 );
290}
291
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look292function WorkflowHeader({ workflow, base, member, manage }: { workflow: Workflow; base: string; member: boolean; manage: boolean }) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs293 return (
294 <div className="space-y-4">
295 <div className="flex flex-wrap items-start justify-between gap-3">
296 <div className="min-w-0">
297 <h3 className="text-lg font-semibold tracking-tight">{workflow.name}</h3>
298 <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg">
299 <FileCode2 size={12} />
300 {workflow.path}
301 </Link>
302 {workflow.events.length > 0 && (
303 <p className="mt-1.5 text-xs text-muted">On {workflow.events.map((e) => EVENT_WORDS[e] ?? e).join(", ")}</p>
304 )}
305 </div>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)306 {!workflow.error && (
307 <div className="flex flex-wrap items-center gap-2">
308 <StatusBadge workflow={workflow} repo={base.slice(1)} />
309 {member && workflow.dispatch && workflow.state === "active" && <RunWorkflow workflow={workflow} />}
310 {member && manage && <Form method="post">
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs311 <input type="hidden" name="intent" value="toggle" />
312 <input type="hidden" name="workflow" value={workflow.id} />
313 <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} />
Merge branch 'worktree-agent-a985c01f43538af01'314 <SubmitButton
315 variant="quiet"
316 match={{ intent: "toggle", workflow: workflow.id }}
317 pending={workflow.state === "active" ? "Turning off…" : "Turning on…"}
318 >
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs319 {workflow.state === "active" ? "Turn off" : "Turn on"}
Merge branch 'worktree-agent-a985c01f43538af01'320 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look321 </Form>}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs322 </div>
323 )}
324 </div>
325 {workflow.error && <p className="rounded-lg bg-danger/10 px-3 py-2 text-sm text-danger">{workflow.error}</p>}
326 {workflow.state === "disabled" && (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look327 <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">Turned off: nothing starts it until someone with the Maintain role turns it on.</p>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs328 )}
329 <Notes notes={workflow.notes} />
330 </div>
331 );
332}
333
334const EXAMPLE = `name: CI
335on:
336 push:
337 branches: [main]
338 pull_request:
339jobs:
340 test:
341 runs-on: ubuntu-latest
342 steps:
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24343 - uses: actions/checkout@v7
344 - uses: actions/setup-node@v7
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs345 with:
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24346 node-version: 24
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs347 - run: npm ci
348 - run: npm test`;
349
350export default function Actions({ loaderData, actionData, params }: Route.ComponentProps) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents351 const { workflows, runs, member, canPush, manage, computeNote, selected } = loaderData;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs352 const [search] = useSearchParams();
353 const base = `/${params.owner}/${params.repo}`;
354 const workflow = workflows.find((w) => w.id === selected || w.path.endsWith(`/${selected}`)) ?? null;
355 useLiveWhile(runs.some((run) => run.status !== "completed"));
356 const started = actionData && "started" in actionData ? actionData.started : null;
357 return (
358 <div className="max-w-6xl">
359 <header className="mb-6 flex flex-wrap items-start justify-between gap-4">
360 <div>
361 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
362 <PlayCircle size={18} className="text-accent" />
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales363 Workflows
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs364 </h2>
365 <p className="mt-1 max-w-2xl text-sm text-muted">
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales366 Workflows in <code className="text-fg">.g1t/workflows</code>, written as GitHub Actions and run as GitHub runs
367 them. A run on a pull request counts as its checks.
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs368 </p>
369 </div>
370 <a href="https://docs.g1t.sh/guides/actions/" className="text-sm text-muted underline underline-offset-4 hover:text-fg">
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales371 How workflows run on g1t
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs372 </a>
373 </header>
374
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look375 {member && computeNote && (
376 <div className="mb-6 flex flex-wrap items-center gap-2 rounded-xl bg-surface px-4 py-3 text-sm ring-1 ring-line">
377 <AlertTriangle size={15} className="shrink-0 text-warn" />
378 <ComputeNote note={computeNote} />
New workspaces are told to connect a model, before anything fails379 </div>
380 )}
381
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs382 {workflows.length === 0 ? (
383 <div className="grid gap-6 lg:grid-cols-2">
Fast pages, required checks on the branch, self-hosted runners, honest incidents384 <div className="lg:col-span-2">
385 <AddCiPrompt owner={params.owner} repo={params.repo} canAdd={canPush} />
386 </div>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs387 <EmptyState title="No workflows yet">
388 Coming from GitHub? Rename <code>.github</code> to <code>.g1t</code> and push: your workflows run here as they
389 are. g1t never reads <code>.github</code>.
390 </EmptyState>
391 <div>
392 <p className="font-mono text-xs text-faint">.g1t/workflows/ci.yml</p>
393 <pre className="mt-1.5 rounded-lg bg-surface p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap ring-1 ring-line">
394 <code>{EXAMPLE}</code>
395 </pre>
396 </div>
397 </div>
398 ) : (
399 <div className="grid gap-8 lg:grid-cols-[14rem_1fr]">
400 <nav aria-label="Workflows" className="space-y-0.5 text-sm">
401 <Link
402 to="?"
403 className={`block rounded-md px-2.5 py-1.5 ${!selected ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}
404 >
405 All workflows
406 </Link>
407 {workflows.map((w) => (
408 <Link
409 key={w.id}
410 to={`?workflow=${w.id}`}
411 className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
412 w.id === workflow?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
413 }`}
414 >
Workspace things at the bottom of the sidebar, and on mission control415 <span className="min-w-0 truncate">
416 {w.name}
417 {/* Two workflows of one name are told apart by their files. */}
418 {workflows.some((other) => other.id !== w.id && other.name === w.name) && (
419 <span className="block truncate font-mono text-[0.6875rem] text-faint">{w.path}</span>
420 )}
421 </span>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs422 {w.error && <AlertTriangle size={12} className="shrink-0 text-danger" aria-label="Its file has a problem" />}
423 {w.state === "disabled" && <span className="ml-auto shrink-0 text-xs text-faint">off</span>}
424 </Link>
425 ))}
426 </nav>
427 <div className="min-w-0 space-y-6">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look428 {workflow && <WorkflowHeader workflow={workflow} base={base} member={member} manage={manage} />}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs429 {started && (
430 <p className="text-sm text-muted">
431 Started{" "}
432 <Link to={`${base}/actions/runs/${started.id}`} className="text-fg underline underline-offset-4">
433 run #{started.number}
434 </Link>
435 .
436 </p>
437 )}
438 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
439 {runs.length === 0 ? (
440 <EmptyState title="No runs yet">
441 {workflow?.dispatch ? "Run it by hand, or wait for what starts it." : "Runs appear here when something starts one."}
442 </EmptyState>
443 ) : (
444 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
445 {runs.map((run) => (
446 <RunRow key={run.id} run={run} base={base} showWorkflow={!search.get("workflow")} />
447 ))}
448 </ul>
449 )}
450 </div>
451 </div>
452 )}
453 </div>
454 );
455}

This file's history is long; its oldest lines are credited to the oldest commit read.