Skip to content
1,234 linesCodeBlameRaw

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 {
2 AlertTriangle,
3 Bug,
4 Check,
5 ChevronDown,
6 ChevronRight,
7 Cloud,
8 Download,
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way9 FileCode,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)10 FileText,
11 GitBranch,
12 GitCommitHorizontal,
13 History,
14 Hourglass,
15 Info,
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way16 LayoutList,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)17 Package,
18 Play,
19 RotateCw,
20 Search,
21 ServerCog,
22 ShieldAlert,
23 Square,
24 Trash2,
25 Users,
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way26 Workflow,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)27 X,
28 XCircle,
29} from "lucide-react";
30import { type ReactNode, useEffect, useRef, useState } from "react";
Merge branch 'worktree-agent-a985c01f43538af01'31import { Form, Link, useLoaderData, useSearchParams } from "react-router";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs32
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)33import type { Annotation, EnvironmentReviewer, Job, JobSummary, PendingDeployment, RunApproval, RunAttempt, StepState } from "@g1t/contracts";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs34
35import type { Route } from "./+types/actions-run";
Social cards for every page: og.g1t.sh36import { page } from "../../lib/meta";
Merge remote-tracking branch 'origin/main' into workspace-chat37import { Duration, LogText, Notes, StatusIcon, shortRef, standingWord, useJobLog } from "../../components/actions";
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)38import { Markdown } from "../../components/markdown";
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way39import { RunGraph } from "../../components/run-graph";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)40import { DetailsDisclosure } from "../../components/details-disclosure";
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)41import { Button, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
42import { CheckboxOption } from "../../components/ui/checkbox";
43import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
44import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
Merge branch 'worktree-agent-ae1299e92e4462012'45import { Hint } from "../../components/ui/hint";
Merge branch 'mirroring' into artifacts-mode46import { useWorkflowReason } from "../../components/mirror";
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)47import { searchLog } from "../../lib/log-lines";
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails48import { listArtifacts, withLegacyArtifacts } from "../../lib/artifacts.server";
49import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/artifacts";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs50import { actions } from "../../lib/services.server";
Runners are owner-only: the page, its actions, the Settings entry and the links to it51import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look52import { accessTo, refusal } from "../../lib/access.server";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending53import { useRefreshWhile } from "../../lib/refresh";
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way54import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs55
Social cards for every page: og.g1t.sh56export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs57 const run = loaderData?.detail.run;
Social cards for every page: og.g1t.sh58 return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` });
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs59}
60
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)61export async function loader({ params, context, request }: Route.LoaderArgs) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs62 const viewer = getViewer(context);
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R263 const repo = { namespace: params.owner, name: params.repo };
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)64 // An earlier attempt, when one is asked for.
65 const asked = Number(new URL(request.url).searchParams.get("attempt") ?? "");
66 const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined;
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails67 const [detail, kept, summaries] = await Promise.all([
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)68 actions.run(repo, viewer, params.id, attempt).then(unwrap),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R269 listArtifacts(repo, viewer, params.id).catch(() => []),
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)70 actions
71 .summaries(repo, viewer, params.id, attempt)
72 .then((found) => (found.ok ? found.value : []))
73 .catch((): JobSummary[] => []),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R274 ]);
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails75 // Artifacts an older runner kept in KV: asked for only once the service
76 // has shown the viewer the run, and never while it is still going.
77 const artifacts = legacyArtifactsWorthAsking(detail.run) ? await withLegacyArtifacts(kept, params.id) : kept;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look78 // Cancelling and re-running need Write.
Runners are owner-only: the page, its actions, the Settings entry and the links to it79 return {
80 detail,
81 artifacts,
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)82 summaries,
Runners are owner-only: the page, its actions, the Settings entry and the links to it83 member: (await accessTo(context, params)).can.run,
84 // The runners page is the workspace owners'.
85 runnersPage: roleIn(viewer, params.owner) === "owner",
86 };
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs87}
88
89export async function action({ request, params, context }: Route.ActionArgs) {
90 assertSameOrigin(request);
91 const user = requireUser(context, request);
92 const repo = { namespace: params.owner, name: params.repo };
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R293 const form = await request.formData();
94 const intent = String(form.get("intent"));
Merge branch 'worktree-agent-a3abfcce648e87dca'95 // Reviewing a deployment is the environment's reviewers' to do: the
96 // service says who may.
97 if (intent === "approve-deployment" || intent === "reject-deployment") {
98 const environment = String(form.get("environment") ?? "");
99 const comment = String(form.get("comment") ?? "").trim();
100 const done = await actions.reviewDeployments(
101 user,
102 repo,
103 params.id,
104 intent === "approve-deployment" ? "approved" : "rejected",
105 environment ? [environment] : [],
106 comment || undefined,
107 );
108 return done.ok ? {} : { error: done.error.message };
109 }
110 // Everything else, approving a run from outside included, needs Write.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look111 const refused = await refusal(context, params, "run");
112 if (refused) return { error: refused };
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2113 if (intent === "delete-artifact") {
114 const deleted = await actions.deleteArtifact(user, repo, Number(form.get("artifact")));
115 return deleted.ok ? {} : { error: deleted.error.message };
116 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)117 // Debug logging, for a re-run that asks for it.
118 const debug = form.get("debug") === "on";
119 const job = String(form.get("job") ?? "") || undefined;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs120 const done =
Merge branch 'worktree-agent-a3abfcce648e87dca'121 intent === "approve-run"
122 ? await actions.approveRun(user, repo, params.id)
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)123 : intent === "cancel" || intent === "force-cancel"
124 ? await actions.cancel(user, repo, params.id, intent === "force-cancel")
125 : await actions.rerun(user, repo, params.id, intent === "rerun-failed", { job: intent === "rerun-job" ? job : undefined, debug });
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs126 return done.ok ? {} : { error: done.error.message };
127}
128
129const ANNOTATION_ICON: Record<Annotation["level"], ReactNode> = {
130 error: <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />,
131 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
132 notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
133};
134
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)135function StepRow({
136 step,
137 text,
138 defaultOpen,
139 query = "",
140 matches = 0,
141}: {
142 step: StepState;
143 text: string | undefined;
144 defaultOpen: boolean;
145 /** A search of the job's log: only its matching lines are shown. */
146 query?: string;
147 matches?: number;
148}) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs149 return (
150 <details className="group border-t border-line first:border-t-0" open={defaultOpen}>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)151 <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2.5 px-3 py-2 text-sm hover:bg-raised/40 sm:min-h-0 sm:px-4">
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs152 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
153 <StatusIcon status={step.status} conclusion={step.conclusion} size={14} />
154 <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)155 {query && (
156 <span className="shrink-0 rounded-full bg-accent/15 px-1.5 text-xs text-accent">
157 {matches} {matches === 1 ? "line" : "lines"}
158 </span>
159 )}
Merge remote-tracking branch 'origin/main' into workspace-chat160 <Duration className="ml-auto shrink-0 font-mono text-xs text-faint" start={step.startedAt} end={step.finishedAt} />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs161 </summary>
162 <div className="border-t border-line bg-bg/60">
163 {text === undefined ? (
164 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output yet."}</p>
165 ) : (
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)166 <LogText text={text} query={query} />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs167 )}
168 </div>
169 </details>
170 );
171}
172
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)173/**
174 * Runs again, after asking whether with debug logging: the whole run, its
175 * failed jobs, or one job (with the jobs that need it).
176 */
177function RerunDialog({
178 intent,
179 job,
180 title,
181 description,
182 children,
183 busy,
184}: {
185 intent: "rerun" | "rerun-failed" | "rerun-job";
186 job?: string;
187 title: string;
188 description: string;
189 /** What opens it. */
190 children: (open: () => void) => ReactNode;
191 busy: boolean;
192}) {
193 const [open, setOpen] = useState(false);
194 // Open, saying it is starting, until the new attempt shows or the error does.
195 const running = usePending({ intent });
196 const was = useRef(false);
197 useEffect(() => {
198 if (was.current && !running) setOpen(false);
199 was.current = running;
200 }, [running]);
201 return (
202 <>
203 {children(() => setOpen(true))}
204 <Dialog open={open} onOpenChange={setOpen}>
205 <DialogContent>
206 <Form method="post" preventScrollReset className="grid gap-4">
207 <DialogHeader>
208 <DialogTitle>{title}</DialogTitle>
209 <DialogDescription>{description}</DialogDescription>
210 </DialogHeader>
211 {job && <input type="hidden" name="job" value={job} />}
212 <CheckboxOption
213 name="debug"
214 label="Enable debug logging"
215 description="Sets RUNNER_DEBUG=1 and ACTIONS_STEP_DEBUG: ::debug:: lines are shown, and how each step's if: read."
216 />
217 <DialogFooter>
218 <Button type="button" variant="quiet" onClick={() => setOpen(false)}>
219 Cancel
220 </Button>
221 <SubmitButton name="intent" value={intent} variant="accent" disabled={busy} pending="Re-running…">
222 <RotateCw size={13} />
223 {title}
224 </SubmitButton>
225 </DialogFooter>
226 </Form>
227 </DialogContent>
228 </Dialog>
229 </>
230 );
231}
232
233/** A link or menu button that sits beside the run's quiet buttons, at their size. */
234const QUIET_LINK =
235 "inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg";
236
237/** The run's attempts, newest first, each a link to how it went. */
238function AttemptPicker({ attempts, shown }: { attempts: RunAttempt[]; shown: number }) {
239 const latest = attempts[attempts.length - 1]?.attempt ?? shown;
240 return (
241 <DropdownMenu>
242 <DropdownMenuTrigger asChild>
243 <button type="button" className={QUIET_LINK}>
244 <History size={13} />
245 Attempt #{shown}
246 <ChevronDown size={13} />
247 </button>
248 </DropdownMenuTrigger>
249 <DropdownMenuContent align="end" className="min-w-64">
250 {[...attempts].reverse().map((attempt) => (
251 <DropdownMenuItem key={attempt.attempt} asChild>
252 <Link
253 to={attempt.attempt === latest ? "?" : `?attempt=${attempt.attempt}`}
254 preventScrollReset
255 className={`flex items-center gap-2 ${attempt.attempt === shown ? "text-fg" : ""}`}
256 >
257 <StatusIcon status={attempt.status} conclusion={attempt.conclusion} size={14} />
258 <span className="font-medium">Attempt #{attempt.attempt}</span>
259 {attempt.debug && <Bug size={12} className="text-faint" aria-label="Debug logging" />}
260 <span className="ml-auto truncate text-xs text-faint">
261 {attempt.actor && `${attempt.actor} · `}
262 {attempt.startedAt ? <TimeAgo at={attempt.startedAt} /> : "not started"}
263 </span>
264 </Link>
265 </DropdownMenuItem>
266 ))}
267 </DropdownMenuContent>
268 </DropdownMenu>
269 );
270}
271
272/** What the jobs' steps wrote to $GITHUB_STEP_SUMMARY, a card per job. */
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way273function Summaries({
274 summaries,
275 repo,
276 jobHref,
277}: {
278 summaries: JobSummary[];
279 repo: { namespace: string; name: string };
280 jobHref: (job: { id: string }) => string;
281}) {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)282 if (summaries.length === 0) return null;
283 return (
284 <section aria-label="Job summaries" className="space-y-4">
285 {summaries.map((summary) => (
286 <article key={summary.jobId} className="overflow-hidden rounded-xl border border-line bg-surface">
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way287 <header className="flex min-w-0 items-center gap-2 border-b border-line px-4 py-2.5 text-sm">
288 <FileText size={14} className="shrink-0 text-muted" />
289 <Link to={jobHref({ id: summary.jobId })} preventScrollReset className="min-w-0 truncate font-medium hover:underline">
290 {summary.name}
291 </Link>
292 <span className="shrink-0 text-faint">summary</span>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)293 </header>
294 <div className="space-y-4 px-5 py-4">
295 {summary.steps.map((step) => (
296 <Markdown key={step.step} source={step.markdown} repo={repo} />
297 ))}
298 </div>
299 </article>
300 ))}
301 </section>
302 );
303}
304
New workspaces are told to connect a model, before anything fails305/** Why a job did not run as asked, with "Integrations" linked where it says so. */
306function Reason({ text, workspace }: { text: string; workspace: string }) {
307 const [before, after] = text.split("Integrations");
308 return (
309 <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">
310 {after === undefined ? (
311 text
312 ) : (
313 <>
314 {before}
315 <Link to={`/${workspace}/-/integrations`} className="text-accent hover:underline">
316 Integrations
317 </Link>
318 {after}
319 </>
320 )}
321 </p>
322 );
323}
324
Fast pages, required checks on the branch, self-hosted runners, honest incidents325/** Where the job ran: g1t's own runners, or a self-hosted one by name. */
326function RanOn({ job, workspace }: { job: Job; workspace: string }) {
Runners are owner-only: the page, its actions, the Settings entry and the links to it327 const runnersPage = useLoaderData<typeof loader>().runnersPage;
Fast pages, required checks on the branch, self-hosted runners, honest incidents328 if (job.selfHosted) {
Runners are owner-only: the page, its actions, the Settings entry and the links to it329 const badge = "ml-auto inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-0.5 text-xs text-muted";
330 const label = (
331 <>
Fast pages, required checks on the branch, self-hosted runners, honest incidents332 <ServerCog size={13} />
333 {job.runner ? `Self-hosted: ${job.runner}` : "Self-hosted"}
Runners are owner-only: the page, its actions, the Settings entry and the links to it334 </>
335 );
336 // A link to the runners for those who manage them, a label for everyone else.
337 return runnersPage ? (
Merge branch 'worktree-agent-ae1299e92e4462012'338 <Hint label="Self-hosted runner">
339 <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`}>
340 {label}
341 </Link>
342 </Hint>
Runners are owner-only: the page, its actions, the Settings entry and the links to it343 ) : (
Merge branch 'worktree-agent-ae1299e92e4462012'344 <Hint label="Self-hosted runner">
345 <span className={badge}>{label}</span>
346 </Hint>
Fast pages, required checks on the branch, self-hosted runners, honest incidents347 );
348 }
349 if (!job.startedAt) return null;
350 return (
351 <span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-0.5 text-xs text-muted">
352 <Cloud size={13} />
353 g1t
354 </span>
355 );
356}
357
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)358function JobView({ job, base, rerun }: { job: Job; base: string; rerun: ReactNode }) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs359 const live = job.status !== "completed";
360 const log = useJobLog(`${base}/actions/jobs/${job.id}/log`, live);
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)361 const [query, setQuery] = useState("");
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs362 const failed = job.steps.find((s) => s.conclusion === "failure");
363 const running = job.steps.find((s) => s.status === "in_progress");
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)364 const searching = query.trim().length > 0;
365 const setUp: StepState = {
366 number: 0,
367 name: "Set up job",
368 status: job.startedAt ? "completed" : "queued",
369 conclusion: job.startedAt ? "success" : null,
370 startedAt: job.startedAt,
371 finishedAt: job.startedAt,
372 };
373 // While searching, the steps with a match, each with how many.
374 const steps = [setUp, ...job.steps]
375 .map((step) => ({ step, matches: searching ? searchLog(log.get(step.number) ?? "", query).length : 0 }))
376 .filter(({ matches }) => !searching || matches > 0);
377 const total = steps.reduce((sum, { matches }) => sum + matches, 0);
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs378 return (
379 <section className="min-w-0 space-y-4">
380 <div className="flex flex-wrap items-center gap-3">
Merge branch 'worktree-agent-a3abfcce648e87dca'381 <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={18} />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs382 <h3 className="text-base font-semibold">{job.name}</h3>
383 <span className="text-sm text-muted">
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)384 {job.cancelling ? "Cancelling: running its cleanup steps" : standingWord({ ...job, of: "job" })}
Merge remote-tracking branch 'origin/main' into workspace-chat385 {job.startedAt && (
386 <>
387 {" · "}
388 <Duration start={job.startedAt} end={job.finishedAt} />
389 </>
390 )}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs391 </span>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)392 <span className="ml-auto flex items-center gap-2">
393 {rerun}
394 {job.startedAt && (
395 <Hint label="Download this job's log">
396 <a
397 href={`${base}/actions/jobs/${job.id}/log.txt`}
398 aria-label={`Download the log of ${job.name}`}
399 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg"
400 >
401 <Download size={13} />
402 </a>
403 </Hint>
404 )}
405 <RanOn job={job} workspace={base.split("/")[1]!} />
406 </span>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs407 </div>
New workspaces are told to connect a model, before anything fails408 {job.reason && <Reason text={job.reason} workspace={base.split("/")[1]} />}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs409 {job.annotations.length > 0 && (
410 <ul className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
411 {job.annotations.map((note, index) => (
412 <li key={index} className="flex gap-2">
413 {ANNOTATION_ICON[note.level]}
414 <span className="min-w-0">
415 {note.title && <span className="font-medium">{note.title}: </span>}
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar416 <span className="whitespace-pre-wrap wrap-break-word">{note.message}</span>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs417 {note.file && (
418 <span className="block font-mono text-xs text-faint">
419 {note.file}
420 {note.line != null && `:${note.line}`}
421 </span>
422 )}
423 </span>
424 </li>
425 ))}
426 </ul>
427 )}
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)428 {job.startedAt && (
429 <label className="flex items-center gap-2 rounded-lg border border-line bg-surface px-3 py-1.5 text-sm focus-within:border-accent-dim">
430 <Search size={14} className="shrink-0 text-faint" />
431 <input
432 type="search"
433 value={query}
434 onChange={(event) => setQuery(event.target.value)}
435 placeholder="Search logs"
436 aria-label={`Search the log of ${job.name}`}
437 className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
438 />
439 {searching && (
440 <span className="shrink-0 text-xs text-muted" aria-live="polite">
441 {total} {total === 1 ? "line" : "lines"}
442 </span>
443 )}
444 </label>
445 )}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs446 <div className="overflow-hidden rounded-xl border border-line bg-surface">
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)447 {searching && steps.length === 0 && <p className="px-4 py-3 text-sm text-muted">No line of this job's log holds “{query.trim()}”.</p>}
448 {steps.map(({ step, matches }) => (
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs449 <StepRow
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)450 // Searching opens every step with a match; clearing it puts them back.
451 key={`${step.number}:${searching}`}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs452 step={step}
453 text={log.get(step.number)}
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)454 defaultOpen={searching || (step.number !== 0 && step.number === (failed ?? running)?.number)}
455 query={searching ? query : ""}
456 matches={matches}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs457 />
458 ))}
459 </div>
460 </section>
461 );
462}
463
Merge branch 'worktree-agent-a3abfcce648e87dca'464/** A run held by its environments, in words that say what for: a review, or only a wait timer. */
465function runWord(status: string, deployments: PendingDeployment[]): string | null {
466 if (status !== "waiting") return null;
467 const waiting = deployments.filter((deployment) => deployment.state === "waiting");
468 return waiting.length > 0 && waiting.every((deployment) => !deployment.needsReview) ? "Waiting for a wait timer" : null;
469}
470
471/** Whether a wait timer has yet to run out. */
472function timerRunning(deployment: PendingDeployment): boolean {
473 return Boolean(deployment.waitUntil && new Date(deployment.waitUntil).getTime() > Date.now());
474}
475
476/** Whether environments still hold some of the run's jobs. */
477function held(deployments: PendingDeployment[]): boolean {
478 return deployments.some(
479 (deployment) => deployment.state === "waiting" || (deployment.state === "approved" && timerRunning(deployment)),
480 );
481}
482
483/** `in 12m`, `in 2h 5m`: how long until a time. */
484function untilWords(at: string): string {
485 const minutes = Math.max(1, Math.ceil((new Date(at).getTime() - Date.now()) / 60_000));
486 if (minutes < 60) return `in ${minutes}m`;
487 const hours = Math.floor(minutes / 60);
488 if (hours < 48) return `in ${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ""}`;
489 return `in ${Math.round(hours / 24)}d`;
490}
491
492/** A user by name, a team as @team. */
493function ReviewerChip({ reviewer }: { reviewer: EnvironmentReviewer }) {
494 return (
495 <span className="inline-flex items-center gap-1 rounded-md bg-raised px-1.5 py-0.5 font-mono text-xs text-fg/85">
496 {reviewer.type === "team" && <Users size={11} className="text-muted" />}
497 {reviewer.type === "team" ? `@${reviewer.name}` : reviewer.name}
498 </span>
499 );
500}
501
502/** A pull request's run from outside, waiting for someone with Write to let it start. */
503function ApprovalPanel({ approval, member, busy }: { approval: RunApproval; member: boolean; busy: boolean }) {
504 return (
505 <section className="flex flex-col gap-4 rounded-xl border border-warn/30 bg-warn/5 p-4 sm:flex-row sm:items-start">
506 <ShieldAlert size={18} className="mt-0.5 hidden shrink-0 text-warn sm:block" />
507 <div className="min-w-0 grow space-y-1.5 text-sm">
508 <h3 className="flex items-center gap-2 font-medium">
509 <ShieldAlert size={16} className="shrink-0 text-warn sm:hidden" />
510 Approval required
511 </h3>
512 <p className="text-fg/85">{approval.reason}</p>
513 <p className="text-muted">
514 Nothing in this run starts, and it gets no secrets and no token, until someone with the Write role approves it.
515 {!member && " Ask someone with the Write role on this repository to approve it."}
516 </p>
517 </div>
518 {member && (
519 <Form method="post" className="shrink-0">
520 <SubmitButton name="intent" value="approve-run" variant="accent" disabled={busy} pending="Approving…">
521 <Play size={13} />
522 Approve and run
523 </SubmitButton>
524 </Form>
525 )}
526 </section>
527 );
528}
529
530const DEPLOYMENT_ICON: Record<PendingDeployment["state"], ReactNode> = {
531 waiting: <Hourglass size={15} className="shrink-0 text-warn" />,
532 approved: (
533 <span className="inline-flex shrink-0 rounded-full bg-success/15 p-0.5 text-success">
534 <Check size={11} strokeWidth={3} />
535 </span>
536 ),
537 rejected: (
538 <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger">
539 <X size={11} strokeWidth={3} />
540 </span>
541 ),
542};
543
544/** Where one environment's rules stand for this run, and a way to review it for those who may. */
545function DeploymentRow({ deployment, busy }: { deployment: PendingDeployment; busy: boolean }) {
546 const waiting = deployment.state === "waiting";
547 const timer = timerRunning(deployment);
548 const words =
549 deployment.state === "approved"
550 ? timer
551 ? "Approved · waiting for its wait timer"
552 : "Approved"
553 : deployment.state === "rejected"
554 ? "Rejected"
555 : deployment.needsReview
556 ? "Waiting for review"
557 : "Waiting for its wait timer";
558 return (
559 <li className="space-y-3 px-4 py-4">
560 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
561 {DEPLOYMENT_ICON[deployment.state]}
562 <span className="font-mono text-sm font-medium">{deployment.environment}</span>
563 <span className="text-sm text-muted">{words}</span>
564 </div>
565 <dl className="grid gap-x-4 gap-y-1.5 text-sm sm:grid-cols-[7rem_1fr]">
566 {deployment.jobs.length > 0 && (
567 <>
568 <dt className="text-muted">{deployment.jobs.length === 1 ? "Job" : "Jobs"}</dt>
569 <dd className="flex min-w-0 flex-wrap gap-1.5">
570 {deployment.jobs.map((job) => (
571 <span key={job} className="rounded-md bg-raised px-1.5 py-0.5 text-xs">
572 {job}
573 </span>
574 ))}
575 </dd>
576 </>
577 )}
578 {deployment.reviewers.length > 0 && (
579 <>
580 <dt className="text-muted">Reviewers</dt>
581 <dd className="flex min-w-0 flex-wrap gap-1.5">
582 {deployment.reviewers.map((reviewer) => (
583 <ReviewerChip key={`${reviewer.type}:${reviewer.name}`} reviewer={reviewer} />
584 ))}
585 </dd>
586 </>
587 )}
588 {deployment.waitUntil && (waiting || timer) && (
589 <>
590 <dt className="text-muted">Wait timer</dt>
591 <dd>
592 {timer ? (
593 <>
594 Starts at{" "}
595 <time dateTime={deployment.waitUntil} suppressHydrationWarning>
596 {new Date(deployment.waitUntil).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
597 </time>{" "}
598 <span className="text-muted" suppressHydrationWarning>
599 ({untilWords(deployment.waitUntil)})
600 </span>
601 </>
602 ) : (
603 "Done"
604 )}
605 </dd>
606 </>
607 )}
608 {deployment.reviewedBy && (
609 <>
610 <dt className="text-muted">{deployment.state === "rejected" ? "Rejected by" : "Approved by"}</dt>
611 <dd className="min-w-0">
612 <span className="font-mono text-xs">{deployment.reviewedBy}</span>
613 {deployment.reviewedAt && (
614 <span className="text-muted">
615 {" "}
616 · <TimeAgo at={deployment.reviewedAt} />
617 </span>
618 )}
619 {deployment.comment && <p className="mt-1 whitespace-pre-wrap break-words text-fg/85">{deployment.comment}</p>}
620 </dd>
621 </>
622 )}
623 </dl>
624 {waiting && deployment.needsReview && deployment.canReview && (
625 <Form method="post" className="space-y-3">
626 <input type="hidden" name="environment" value={deployment.environment} />
627 <label className="block">
628 <span className="mb-1.5 block text-xs font-medium text-muted">Comment (optional)</span>
629 <textarea
630 name="comment"
631 rows={2}
632 maxLength={1000}
633 placeholder="Why you approve or reject it"
634 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
635 />
636 </label>
637 <div className="flex flex-wrap gap-2">
638 <SubmitButton name="intent" value="approve-deployment" variant="accent" disabled={busy} pending="Approving…">
639 <Check size={13} />
640 Approve and deploy
641 </SubmitButton>
642 <SubmitButton name="intent" value="reject-deployment" variant="danger" disabled={busy} pending="Rejecting…">
643 <X size={13} />
644 Reject
645 </SubmitButton>
646 </div>
647 </Form>
648 )}
649 {waiting && deployment.needsReview && !deployment.canReview && (
650 <p className="text-sm text-muted">
651 {deployment.reviewers.length > 0
652 ? "Only its reviewers can approve or reject it."
653 : "Only someone this environment's rules name can approve or reject it."}
654 </p>
655 )}
656 </li>
657 );
658}
659
660/** The environments holding the run's jobs, each with where its rules stand. */
661function DeploymentsPanel({ deployments, busy }: { deployments: PendingDeployment[]; busy: boolean }) {
662 const review = deployments.some((deployment) => deployment.state === "waiting" && deployment.needsReview);
663 return (
664 <section className="overflow-hidden rounded-xl border border-line bg-surface">
665 <header className="flex items-start gap-2.5 border-b border-line px-4 py-3">
666 <Hourglass size={16} className="mt-0.5 shrink-0 text-warn" />
667 <div className="min-w-0">
668 <h3 className="text-sm font-medium">{review ? "Waiting for review" : "Waiting to deploy"}</h3>
669 <p className="mt-0.5 text-sm text-muted">
670 Jobs that deploy to these environments wait until each environment's protection rules let them through, and
671 only then get its secrets.
672 </p>
673 </div>
674 </header>
675 <ul className="divide-y divide-line">
676 {deployments.map((deployment) => (
677 <DeploymentRow key={deployment.environment} deployment={deployment} busy={busy} />
678 ))}
679 </ul>
680 </section>
681 );
682}
683
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way684type RunData = Route.ComponentProps["loaderData"];
685
686/** Where the run came from, how it stands, how long it took and what it kept. */
687function RunCard({
688 detail,
689 artifacts,
690 standing,
691 cancelling,
692 base,
693}: {
694 detail: RunData["detail"];
695 artifacts: RunData["artifacts"];
696 standing: string;
697 cancelling: boolean;
698 base: string;
699}) {
700 const { run } = detail;
701 const label = "text-xs text-muted";
702 return (
703 <section
704 aria-label="Run"
705 className="grid gap-x-6 gap-y-4 rounded-xl border border-line bg-surface p-4 text-sm sm:grid-cols-[minmax(0,2fr)_repeat(3,minmax(0,1fr))]"
706 >
707 <div className="min-w-0 space-y-1.5">
708 <p className={label}>
709 Triggered via {run.event} <TimeAgo at={run.createdAt} />
710 </p>
711 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
712 {run.actor && <span className="font-medium">{run.actor}</span>}
713 <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs text-muted hover:text-fg">
714 <GitCommitHorizontal size={12} />
715 {run.sha.slice(0, 7)}
716 </Link>
717 {run.pull != null && /^refs\/pull\//.test(run.ref) ? (
718 <Link to={`${base}/pull/${run.pull}`} className="rounded-md bg-accent/10 px-1.5 py-0.5 font-mono text-xs text-accent hover:underline">
719 #{run.pull}
720 </Link>
721 ) : (
722 <span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent/10 px-1.5 py-0.5 font-mono text-xs text-accent">
723 <GitBranch size={11} className="shrink-0" />
724 <span className="truncate">{shortRef(run.ref)}</span>
725 </span>
726 )}
727 </p>
728 {detail.approval?.state === "approved" && detail.approval.approvedBy && (
729 <p className="text-xs text-muted">Approved by {detail.approval.approvedBy}</p>
730 )}
731 </div>
732 <div className="grid grid-cols-3 gap-4 sm:contents">
733 <div className="min-w-0 space-y-1.5">
734 <p className={label}>Status</p>
735 <p className="flex items-center gap-1.5 font-medium">
736 <StatusIcon status={run.status} conclusion={run.conclusion} size={14} />
737 <span className="truncate">{standing}</span>
738 </p>
739 {cancelling && <p className="text-xs text-warn">Its jobs are running their cleanup steps</p>}
740 </div>
741 <div className="min-w-0 space-y-1.5">
742 <p className={label}>Total duration</p>
743 <p className="font-medium">{run.startedAt ? <Duration start={run.startedAt} end={run.finishedAt} /> : "—"}</p>
744 {run.attempt > 1 && <p className="text-xs text-muted">Attempt #{run.attempt}</p>}
745 </div>
746 <div className="min-w-0 space-y-1.5">
747 <p className={label}>Artifacts</p>
748 <p className="font-medium">
749 {artifacts.length > 0 ? (
750 <a href="#artifacts" className="text-accent hover:underline">
751 {artifacts.length}
752 </a>
753 ) : (
754 "—"
755 )}
756 </p>
757 </div>
758 </div>
759 </section>
760 );
761}
762
763const LEVELS: { level: Annotation["level"]; one: string; many: string }[] = [
764 { level: "error", one: "error", many: "errors" },
765 { level: "warning", one: "warning", many: "warnings" },
766 { level: "notice", one: "notice", many: "notices" },
767];
768
769/** Every job's errors, warnings and notices, folded under how many of each. */
770function AnnotationsPanel({ jobs, jobHref }: { jobs: Job[]; jobHref: (job: { id: string }) => string }) {
771 const notes = jobs.flatMap((job) => job.annotations.map((note) => ({ job, note })));
772 if (notes.length === 0) return null;
773 const counts = LEVELS.flatMap(({ level, one, many }) => {
774 const count = notes.filter(({ note }) => note.level === level).length;
775 return count > 0 ? [`${count} ${count === 1 ? one : many}`] : [];
776 });
777 return (
778 <details open className="group overflow-hidden rounded-xl border border-line bg-surface">
779 <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-4 py-2.5 text-sm hover:bg-raised/40">
780 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
781 <span className="font-medium">Annotations</span>
782 <span className="min-w-0 truncate text-muted">{counts.join(", ")}</span>
783 </summary>
784 <ul className="divide-y divide-line border-t border-line text-sm">
785 {notes.map(({ job, note }, index) => (
786 <li key={index} className="flex gap-2 px-4 py-3">
787 {ANNOTATION_ICON[note.level]}
788 <span className="min-w-0">
789 <Link to={jobHref(job)} preventScrollReset className="block text-xs font-medium text-muted hover:text-fg hover:underline">
790 {job.name}
791 </Link>
792 {note.title && <span className="font-medium">{note.title}: </span>}
793 <span className="whitespace-pre-wrap wrap-break-word">{note.message}</span>
794 {note.file && (
795 <span className="block font-mono text-xs text-faint">
796 {note.file}
797 {note.line != null && `:${note.line}`}
798 </span>
799 )}
800 </span>
801 </li>
802 ))}
803 </ul>
804 </details>
805 );
806}
807
808/** What the run kept, to download (and, with Write, to delete now). */
809function ArtifactsList({
810 artifacts,
811 member,
812 busy,
813 base,
814 runId,
815}: {
816 artifacts: RunData["artifacts"];
817 member: boolean;
818 busy: boolean;
819 base: string;
820 runId: string;
821}) {
822 if (artifacts.length === 0) return null;
823 return (
824 <section id="artifacts" className="scroll-mt-20 rounded-xl border border-line bg-surface p-4">
825 <h3 className="flex items-center gap-2 text-sm font-medium">
826 <Package size={14} className="text-muted" />
827 Artifacts
828 <span className="font-normal text-faint">
829 · {artifacts.length} · {formatBytes(artifacts.reduce((sum, a) => sum + a.size, 0))}
830 </span>
831 </h3>
832 <ul className="mt-3 divide-y divide-line text-sm">
833 {artifacts.map((artifact) => (
834 <li key={artifact.name} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
835 <span className="min-w-0 grow basis-full truncate font-mono text-[0.8125rem] sm:basis-40">{artifact.name}</span>
836 <span className="flex shrink-0 items-center gap-3 text-xs text-faint">
837 <span>{formatBytes(artifact.size)}</span>
838 {artifact.expiresAt && <span>{expiresIn(artifact.expiresAt)}</span>}
839 </span>
840 <span className="ml-auto flex shrink-0 items-center gap-1.5">
841 <a
842 href={`${base}/actions/runs/${runId}/artifacts/${encodeURIComponent(artifact.name)}`}
843 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted ring-1 ring-line hover:text-fg"
844 >
845 <Download size={12} />
846 Download
847 </a>
848 {member && artifact.id != null && (
849 <Form method="post" preventScrollReset>
850 <input type="hidden" name="artifact" value={artifact.id} />
851 <Hint label={`Delete ${artifact.name} now`}>
852 <SubmitButton
853 name="intent"
854 value="delete-artifact"
855 icon
856 disabled={busy}
857 aria-label={`Delete ${artifact.name}`}
858 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-danger disabled:opacity-50"
859 >
860 <Trash2 size={12} />
861 </SubmitButton>
862 </Hint>
863 </Form>
864 )}
865 </span>
866 </li>
867 ))}
868 </ul>
869 </section>
870 );
871}
872
873/** The run at a glance: the card, its jobs as a graph, their annotations, summaries and artifacts. */
874function RunSummary({
875 detail,
876 artifacts,
877 summaries,
878 standing,
879 cancelling,
880 member,
881 busy,
882 base,
883 jobHref,
884}: {
885 detail: RunData["detail"];
886 artifacts: RunData["artifacts"];
887 summaries: JobSummary[];
888 standing: string;
889 cancelling: boolean;
890 member: boolean;
891 busy: boolean;
892 base: string;
893 jobHref: (job: { id: string }) => string;
894}) {
895 const { run, jobs } = detail;
896 const [namespace = "", name = ""] = base.slice(1).split("/");
897 return (
898 <section aria-label="Summary" className="min-w-0 space-y-4">
899 <RunCard detail={detail} artifacts={artifacts} standing={standing} cancelling={cancelling} base={base} />
900 <RunGraph jobs={jobs} title={run.path.split("/").pop() || run.name} trigger={run.event} href={jobHref} />
901 <AnnotationsPanel jobs={jobs} jobHref={jobHref} />
902 <Summaries summaries={summaries} repo={{ namespace, name }} jobHref={jobHref} />
903 <ArtifactsList artifacts={artifacts} member={member} busy={busy} base={base} runId={run.id} />
904 </section>
905 );
906}
907
908const NAV_ROW = "flex min-h-11 items-center gap-2 rounded-md px-2.5 py-1.5 sm:min-h-0";
909
910function navRow(active: boolean): string {
911 return `${NAV_ROW} ${active ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`;
912}
913
914/** A job's row in the jobs list. */
915function NavJob({ job, label, selected, jobHref }: { job: GraphJob; label: string; selected: Job | null; jobHref: (job: { id: string }) => string }) {
916 const active = job.id === selected?.id;
917 return (
918 <Link to={jobHref(job)} preventScrollReset aria-current={active ? "page" : undefined} className={navRow(active)}>
919 <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={14} />
920 <span className="min-w-0 truncate">{label}</span>
921 <Duration className="ml-auto shrink-0 font-mono text-xs text-faint" start={job.startedAt} end={job.finishedAt} />
922 </Link>
923 );
924}
925
926/** One unit of the jobs list: a job, or a matrix or called workflow folded under its name. */
927function NavUnit({ unit, selected, jobHref }: { unit: Unit; selected: Job | null; jobHref: (job: { id: string }) => string }) {
928 if (unit.kind === "job") return <NavJob job={unit.job} label={unit.label} selected={selected} jobHref={jobHref} />;
929 const inside = jobsOf(unit).some((job) => job.id === selected?.id);
930 const standing = unitStanding(unit);
931 return (
932 <details open={unit.kind === "call" || inside} className="group/unit">
933 <summary className={`${NAV_ROW} cursor-pointer list-none text-muted hover:bg-raised/60 hover:text-fg`}>
934 <ChevronRight size={13} className="shrink-0 text-faint transition-transform group-open/unit:rotate-90" />
935 <StatusIcon
936 status={standing.status === "calling" ? "in_progress" : standing.status}
937 conclusion={standing.conclusion}
938 of="job"
939 size={14}
940 />
941 <span className="min-w-0 truncate">{unit.label}</span>
942 {unit.kind === "matrix" && <span className="ml-auto shrink-0 text-xs text-faint">{unit.jobs.length}</span>}
943 </summary>
944 <div className="ml-3 space-y-0.5 border-l border-line pl-1.5">
945 {unit.kind === "matrix" ? (
946 unit.jobs.map((job) => (
947 <NavJob key={job.id} job={job} label={job.name.split(" / ").pop() ?? job.name} selected={selected} jobHref={jobHref} />
948 ))
949 ) : (
950 <>
951 {unit.callers.map((job) => (
952 <Link
953 key={job.id}
954 to={jobHref(job)}
955 preventScrollReset
956 aria-current={job.id === selected?.id ? "page" : undefined}
957 className={navRow(job.id === selected?.id)}
958 >
959 <Workflow size={14} className="shrink-0 text-faint" />
960 <span className="min-w-0 truncate">{unit.uses ? `Calls ${unit.uses.split("/").pop()}` : job.name}</span>
961 </Link>
962 ))}
963 {unit.units.map((inner) => (
964 <NavUnit key={inner.key} unit={inner} selected={selected} jobHref={jobHref} />
965 ))}
966 </>
967 )}
968 </div>
969 </details>
970 );
971}
972
973/** The run's pages: its summary, its jobs grouped as the graph groups them, and where it came from. */
974function RunNav({
975 run,
976 jobs,
977 selected,
978 base,
979 jobHref,
980 summaryHref,
981}: {
982 run: RunData["detail"]["run"];
983 jobs: Job[];
984 selected: Job | null;
985 base: string;
986 jobHref: (job: { id: string }) => string;
987 summaryHref: string;
988}) {
989 const units = groupJobs(jobs);
990 const heading = "px-2.5 pt-4 pb-1 text-xs font-medium text-faint";
991 return (
992 <nav aria-label="Run" className="space-y-0.5 text-sm">
993 <Link to={summaryHref} preventScrollReset aria-current={selected ? undefined : "page"} className={navRow(!selected)}>
994 <LayoutList size={14} className="shrink-0" />
995 Summary
996 </Link>
997 <p className={heading}>All jobs</p>
998 {units.map((unit) => (
999 <NavUnit key={unit.key} unit={unit} selected={selected} jobHref={jobHref} />
1000 ))}
1001 <p className={heading}>Run details</p>
1002 <Link to={`${base}/blob/${run.sha}/${run.path}`} className={navRow(false)}>
1003 <FileCode size={14} className="shrink-0" />
1004 Workflow file
1005 </Link>
1006 </nav>
1007 );
1008}
1009
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1010export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1011 const { detail, artifacts, member, summaries } = loaderData;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1012 const { run, jobs, notes } = detail;
Merge branch 'worktree-agent-a3abfcce648e87dca'1013 const deployments = detail.pendingDeployments ?? [];
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1014 const attempts = detail.attempts ?? [];
1015 const latest = attempts.length === 0 || run.attempt === attempts[attempts.length - 1]!.attempt;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1016 const base = `/${params.owner}/${params.repo}`;
1017 const [search] = useSearchParams();
Merge branch 'worktree-agent-a985c01f43538af01'1018 // One of the run's buttons is working: the others wait for it.
1019 const busy = usePending();
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1020 const live = run.status !== "completed";
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1021 // Cancelled, while its jobs run their cleanup steps.
1022 const cancelling = live && run.conclusion === "cancelled";
1023 // Re-running is for the latest attempt of a finished run.
1024 const canRerun = member && !run.error && !live && latest;
Merge branch 'mirroring' into artifacts-mode1025 // A mirror standing by runs nothing; in CI failover it runs them again.
1026 const rerunBlocked = useWorkflowReason();
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1027 const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`;
Merge branch 'worktree-agent-a3abfcce648e87dca'1028 // Waiting on a person needs no quick refresh; a running job does.
1029 useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : 2500);
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1030
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way1031 // The job asked for; none is the run's summary.
1032 const selected = jobs.find((job) => job.id === search.get("job")) ?? null;
1033 const jobHref = (job: { id: string }) => (latest ? `?job=${job.id}` : `?attempt=${run.attempt}&job=${job.id}`);
1034 const summaryHref = latest ? "?" : `?attempt=${run.attempt}`;
1035 const standing = runWord(run.status, deployments) ?? (cancelling ? "Cancelling" : standingWord(run));
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1036 const anyFailed = jobs.some((job) => job.conclusion === "failure" || job.conclusion === "cancelled");
1037
1038 return (
1039 <div className="max-w-6xl space-y-6">
1040 <header className="space-y-3">
1041 <Link to={`${base}/actions?workflow=${run.workflowId}`} className="text-sm text-muted hover:text-fg">
1042 {run.name}
1043 </Link>
1044 <div className="flex flex-wrap items-start justify-between gap-4">
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1045 <h2 className="flex min-w-0 items-start gap-2.5 text-xl font-semibold tracking-tight sm:items-center">
1046 <span className="mt-1 shrink-0 sm:mt-0">
1047 <StatusIcon status={run.status} conclusion={run.conclusion} size={20} />
1048 </span>
1049 <span className="min-w-0 wrap-break-word sm:truncate">{run.title || run.name}</span>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1050 <span className="font-normal text-muted">#{run.number}</span>
1051 </h2>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1052 <div className="flex flex-wrap items-center gap-2">
1053 {attempts.length > 1 && <AttemptPicker attempts={attempts} shown={run.attempt} />}
1054 {jobs.some((job) => job.startedAt) && (
1055 <Hint label={latest ? "Every job's log, as a zip" : `Attempt #${run.attempt}'s logs, as a zip`}>
1056 <a href={logsUrl} className={QUIET_LINK}>
1057 <Download size={13} />
1058 Download logs
1059 </a>
1060 </Hint>
1061 )}
1062 {member && !run.error && live && (
1063 <Form method="post" className="flex gap-2">
1064 {cancelling ? (
1065 <Hint label="Stop its jobs now, without waiting for their cleanup steps">
1066 <SubmitButton name="intent" value="force-cancel" variant="danger" disabled={busy} pending="Stopping…">
1067 <Square size={13} />
1068 Force cancel
Merge branch 'worktree-agent-a985c01f43538af01'1069 </SubmitButton>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1070 </Hint>
1071 ) : (
1072 <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…">
1073 <Square size={13} />
1074 Cancel run
1075 </SubmitButton>
1076 )}
1077 </Form>
1078 )}
1079 {canRerun && anyFailed && (
1080 <RerunDialog
1081 intent="rerun-failed"
1082 title="Re-run failed jobs"
1083 description="The jobs that did not succeed run again as a new attempt, with every job that needs them. The others keep how they ended."
1084 busy={busy}
1085 >
1086 {(open) => (
Merge branch 'mirroring' into artifacts-mode1087 <Hint label={rerunBlocked} disabled={rerunBlocked != null}>
1088 <Button type="button" variant="quiet" disabled={busy || rerunBlocked != null} onClick={open}>
1089 <RotateCw size={13} />
1090 Re-run failed jobs
1091 </Button>
1092 </Hint>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1093 )}
1094 </RerunDialog>
1095 )}
1096 {canRerun && (
1097 <RerunDialog
1098 intent="rerun"
1099 title="Re-run all jobs"
1100 description="Every job runs again as a new attempt. This attempt stays here, with its logs."
1101 busy={busy}
1102 >
1103 {(open) => (
Merge branch 'mirroring' into artifacts-mode1104 <Hint label={rerunBlocked} disabled={rerunBlocked != null}>
1105 <Button type="button" variant="quiet" disabled={busy || rerunBlocked != null} onClick={open}>
1106 <RotateCw size={13} />
1107 Re-run all jobs
1108 </Button>
1109 </Hint>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1110 )}
1111 </RerunDialog>
1112 )}
1113 </div>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1114 </div>
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way1115 {selected && (
1116 <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
1117 <span>{standing}</span>
1118 {/* A pull request's ref is its number, which the link beside it already shows. */}
1119 {!(run.pull != null && /^refs\/pull\//.test(run.ref)) && (
1120 <span className="inline-flex items-center gap-1 font-mono text-xs">
1121 <GitBranch size={12} />
1122 {shortRef(run.ref)}
1123 </span>
1124 )}
1125 <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
1126 <GitCommitHorizontal size={12} />
1127 {run.sha.slice(0, 7)}
1128 </Link>
1129 {run.pull != null && (
1130 <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg">
1131 #{run.pull}
1132 </Link>
1133 )}
1134 <span>
1135 {run.event}
1136 {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} />
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1137 </span>
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way1138 {run.startedAt && <Duration className="font-mono text-xs" start={run.startedAt} end={run.finishedAt} />}
1139 {run.attempt > 1 && attempts.length <= 1 && <span>Attempt {run.attempt}</span>}
1140 {cancelling && <span className="text-warn">Its jobs are running their cleanup steps</span>}
1141 {detail.approval?.state === "approved" && detail.approval.approvedBy && <span>Approved by {detail.approval.approvedBy}</span>}
1142 </p>
1143 )}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1144 </header>
1145
1146 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1147 {!latest && (
1148 <p className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted">
1149 <History size={15} className="shrink-0" />
1150 <span>
1151 This is attempt #{run.attempt} of {attempts.length}, as it ended.
1152 </span>
1153 <Link to="?" className="text-accent hover:underline">
1154 See the latest attempt
1155 </Link>
1156 </p>
1157 )}
Merge branch 'worktree-agent-a3abfcce648e87dca'1158 {detail.approval?.state === "required" && <ApprovalPanel approval={detail.approval} member={member} busy={busy} />}
1159 {live && held(deployments) && <DeploymentsPanel deployments={deployments} busy={busy} />}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1160 {run.error && (
1161 <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm">
1162 <p className="font-medium text-danger">The workflow file could not be used</p>
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar1163 <p className="mt-1 whitespace-pre-wrap wrap-break-word text-fg/85">{run.error}</p>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1164 </div>
1165 )}
1166 <Notes notes={notes} />
1167
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way1168 {jobs.length === 0 ? (
1169 <RunSummary
1170 detail={detail}
1171 artifacts={artifacts}
1172 summaries={summaries}
1173 standing={standing}
1174 cancelling={cancelling}
1175 member={member}
1176 busy={busy}
1177 base={base}
1178 jobHref={jobHref}
1179 />
1180 ) : (
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1181 <div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way1182 <DetailsDisclosure label="Jobs" summary={selected ? `${jobs.length} · ${selected.name}` : `${jobs.length} · Summary`} bodyClassName="space-y-0.5">
1183 <RunNav run={run} jobs={jobs} selected={selected} base={base} jobHref={jobHref} summaryHref={summaryHref} />
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1184 </DetailsDisclosure>
Merge branch 'worktree-agent-a985c01f43538af01'1185 {/* Run again, a job keeps its id but its log starts afresh. */}
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way1186 {selected ? (
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1187 <JobView
1188 key={`${selected.id}:${run.attempt}`}
1189 job={selected}
1190 base={base}
1191 rerun={
1192 canRerun && selected.status === "completed" ? (
1193 <RerunDialog
1194 intent="rerun-job"
1195 job={selected.id}
1196 title="Re-run this job"
1197 description={`${selected.name} runs again as a new attempt, with every job that needs it. A job of a matrix runs again with the rest of its matrix.`}
1198 busy={busy}
1199 >
1200 {(open) => (
Merge branch 'mirroring' into artifacts-mode1201 <Hint label={rerunBlocked ?? "Re-run this job"} disabled={rerunBlocked != null}>
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1202 <button
1203 type="button"
1204 aria-label={`Re-run ${selected.name}`}
Merge branch 'mirroring' into artifacts-mode1205 disabled={busy || rerunBlocked != null}
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1206 onClick={open}
1207 className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg disabled:opacity-50"
1208 >
1209 <RotateCw size={13} />
1210 </button>
1211 </Hint>
1212 )}
1213 </RerunDialog>
1214 ) : null
1215 }
1216 />
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way1217 ) : (
1218 <RunSummary
1219 detail={detail}
1220 artifacts={artifacts}
1221 summaries={summaries}
1222 standing={standing}
1223 cancelling={cancelling}
1224 member={member}
1225 busy={busy}
1226 base={base}
1227 jobHref={jobHref}
1228 />
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1229 )}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1230 </div>
1231 )}
1232 </div>
1233 );
1234}

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