Skip to content

Commit

Loading: nothing pops in, nothing shifts, and leaving a page shows it

Skeleton pieces in components/ui/skeleton.tsx, each the height of what it stands for: a line of text by 1lh, a timeline item, a card, table rows, a sidebar section, a stat tile and code, with Loading for a busy region that says "Loading…" to screen readers. Mission control's and the project overview's outlines now follow their real layout, and the overview fades in over its outline. The files table's last-commit cells hold a full line while they wait, so the rows keep their height and the README no longer moves down when the column arrives (it moved about 1.5px a row: 27px on flagon-io/g1t). A pull request's Agent panel came after the page: its runs were fetched once the page was up, then what the agent did after that. The runs are now read in the pull request's loader, beside everything else, and the panel keeps polling from there; "What it did" is there from the start, with a skeleton until its log arrives. The pull request list's agent badges and the issue sidebar's agent step line come with their pages the same way. The checks wait in place while their jobs are read: busy, with a skeleton where each workflow's time goes. A preview card holds a skeleton for the previews built against it, when projects use this one. While the next page loads, the one being left dims after a moment under the progress bar, and the next fades up from there, so a click never looks like a frozen page and then a jump. Sending a form does not dim the page.

syntaqxcommitted Parent26b4364Browse files
12 files+359−680/12 viewed
+45−13
304304 };
305305 }
306306
307−type Live = { runs: AgentRun[]; member: boolean };
307+export type Live = { runs: AgentRun[]; member: boolean };
308308
309309 /**
310310 * The runs of a project, fetched from its `agents.json`, every few seconds
311− * while any is running. For pages whose own loader does not have them.
311+ * while any is running. A page whose loader read them passes them as
312+ * `initial`, so they come with the page instead of appearing after it;
313+ * without, they are fetched once it is up.
312314 */
313−export function useRuns(owner: string, repo: string, query: Record<string, string>): Live | null {
315+export function useRuns(
316+ owner: string,
317+ repo: string,
318+ query: Record<string, string>,
319+ initial?: Live | null,
320+): Live | null {
314321 const fetcher = useFetcher<Live>();
315322 const search = new URLSearchParams(query).toString();
316323 const url = `/${owner}/${repo}/agents.json?${search}`;
317324 const { load } = fetcher;
325+ const loaded = initial !== undefined;
318326 useEffect(() => {
319− load(url);
320− }, [load, url]);
321− const live = fetcher.data?.runs.some((run) => isActiveRun(run.status)) ?? false;
327+ if (!loaded) load(url);
328+ }, [load, url, loaded]);
329+ // The newer of the page's runs and the last poll: the page revalidating
330+ // replaces an older poll, and a poll the runs the page came with.
331+ const [newest, setNewest] = useState<Live | null>(initial ?? null);
332+ useEffect(() => {
333+ if (loaded) setNewest(initial ?? null);
334+ }, [loaded, initial]);
335+ useEffect(() => {
336+ if (fetcher.data) setNewest(fetcher.data);
337+ }, [fetcher.data]);
338+ const data = loaded ? newest : (fetcher.data ?? null);
339+ const live = data?.runs.some((run) => isActiveRun(run.status)) ?? false;
322340 useEffect(() => {
323341 if (!live) return;
324342 const timer = setInterval(() => {
326344 }, LIVE_MS);
327345 return () => clearInterval(timer);
328346 }, [live, load, url]);
329− return fetcher.data ?? null;
347+ return data;
330348 }
331349
332350 /** "Agent confidence: Low — tests not added, 3 revisions", and what the agent said it was unsure of. */
361379 number,
362380 stage,
363381 confidence,
382+ runs: initial,
364383 }: {
365384 owner: string;
366385 repo: string;
368387 stage?: Stage | null;
369388 /** How sure g1t is of the change, once the agent has finished it. */
370389 confidence?: Confidence | null;
390+ /** Its latest five runs, from the page's loader, so the panel comes with the page. */
391+ runs?: Live | null;
371392 }) {
372− const data = useRuns(owner, repo, { number: String(number), limit: "5" });
393+ const data = useRuns(owner, repo, { number: String(number), limit: "5" }, initial);
373394 const runs = data?.runs ?? [];
374395 const member = data?.member ?? false;
375396 const current = runs.find((run) => isActiveRun(run.status)) ?? runs[0];
464485 }
465486
466487 /** The active run on each pull request of a project, by number. */
467−export function useActiveRuns(owner: string, repo: string): Map<number, AgentRun> {
468− const data = useRuns(owner, repo, { active: "1", limit: "100" });
488+export function useActiveRuns(owner: string, repo: string, initial?: Live | null): Map<number, AgentRun> {
489+ const data = useRuns(owner, repo, { active: "1", limit: "100" }, initial);
469490 return useMemo(() => {
470491 const byNumber = new Map<number, AgentRun>();
471492 for (const run of data?.runs ?? []) {
490511 * What the agent on a pull request is doing this minute, as one line, for
491512 * an issue's sidebar: which agent picked the issue up, and where it is.
492513 */
493−export function AgentStepLine({ owner, repo, number }: { owner: string; repo: string; number: number }) {
494− const data = useRuns(owner, repo, { number: String(number), active: "1", limit: "1" });
495− const run = data?.runs[0];
514+export function AgentStepLine({
515+ owner,
516+ repo,
517+ number,
518+ runs: initial,
519+}: {
520+ owner: string;
521+ repo: string;
522+ number: number;
523+ /** The project's active runs, from the page's loader, so the line comes with the page. */
524+ runs?: Live | null;
525+}) {
526+ const data = useRuns(owner, repo, { number: String(number), active: "1", limit: "1" }, initial);
527+ const run = data?.runs.find((run) => run.number === number && isActiveRun(run.status));
496528 if (!run?.step) return null;
497529 return (
498530 <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={shownStep(run.step)}>
+10−2
1111
1212 import { actionLabel, actorLabel, ruleLabel, targetLabel } from "../lib/audit";
1313 import { Avatar, TimeAgo } from "./ui";
14+import { Loading, SkeletonRows } from "./ui/skeleton";
1415
1516 function clock(at: string): string {
1617 return new Date(at).toISOString().slice(11, 19);
168169 return () => clearInterval(timer);
169170 }, [live, load, url]);
170171 const entries = fetcher.data?.entries;
171− if (!entries || entries.length === 0) return null;
172+ if (runIds.length === 0) return null;
173+ // There from the start, so the panel does not grow when the log arrives.
172174 return (
173175 <details className="mt-3 group">
174176 <summary className="cursor-pointer text-xs text-muted hover:text-fg">What it did</summary>
175− <WhatItDid entries={entries} compact />
177+ {entries ? (
178+ <WhatItDid entries={entries} compact />
179+ ) : (
180+ <Loading className="mt-3 overflow-hidden rounded-xl border border-line bg-surface">
181+ <SkeletonRows rows={3} rowClassName="h-9 px-4" />
182+ </Loading>
183+ )}
176184 <Link to={`/${owner}/-/audit?project=${encodeURIComponent(repo)}&kind=agent`} className="mt-2 inline-block text-xs text-muted hover:text-fg">
177185 Open the audit log
178186 </Link>
+16−4
2727 import { useAddresses } from "../lib/addresses";
2828 import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up";
2929 import { duration } from "./actions";
30−import { Elapsed, useRuns } from "./agents";
30+import { Elapsed, type Live, useRuns } from "./agents";
3131 import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "./ui";
3232 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
33+import { SkeletonLine } from "./ui/skeleton";
3334
3435 // --- Output ---------------------------------------------------------------
3536
210211 name,
211212 detail,
212213 time,
214+ timing = false,
213215 to,
214216 }: {
215217 standing: Standing;
216218 name: React.ReactNode;
217219 detail?: React.ReactNode;
218220 time?: string;
221+ /** Its jobs and their times are still being read. */
222+ timing?: boolean;
219223 to?: string;
220224 }) {
221225 return (
225229 {name}
226230 {detail && <span className="text-muted"> — {detail}</span>}
227231 </span>
228− {time && <span className="shrink-0 font-mono text-xs text-faint">{time}</span>}
232+ {time && <span className="shrink-0 animate-fade-in font-mono text-xs text-faint">{time}</span>}
233+ {timing && <SkeletonLine className="w-10 shrink-0 text-xs" />}
229234 {to && (
230235 <Link to={to} className="shrink-0 text-xs text-muted hover:text-fg hover:underline">
231236 Details
313318 canRerunWorkflows,
314319 settingsUrl,
315320 error,
321+ loading = false,
316322 }: {
317323 /** Set when the merge queue took it out, or for a run from before checks were workflows. */
318324 run: CheckRun | null;
328334 /** Where the required checks are chosen, for those who may. */
329335 settingsUrl?: string | null;
330336 error?: string | null;
337+ /** The workflow runs' jobs are still being read: their rows wait, busy. */
338+ loading?: boolean;
331339 }) {
332340 if (!run && statuses.length === 0 && required.length === 0) return null;
333341
379387 const failedRuns = statuses.filter((status) => statusStanding(status) === "failed" && runIdOf(status));
380388
381389 return (
382− <div>
390+ <div aria-busy={loading || undefined}>
383391 <div className="flex items-start gap-3 px-4 py-3">
384392 <span className="mt-0.5 shrink-0">
385393 {standing === "failed" || queueFailed ? (
466474 standing={statusStanding(status)}
467475 name={status.context}
468476 detail={status.description ?? undefined}
477+ timing={loading && id != null}
469478 to={runPath}
470479 />
471480 );
775784 update,
776785 startedAt,
777786 retrying,
787+ runs: initial,
778788 }: {
779789 owner: string;
780790 repo: string;
786796 startedAt: number;
787797 /** Whether a retry is on its way. */
788798 retrying: boolean;
799+ /** The pull request's latest runs, from the page's loader. */
800+ runs?: Live | null;
789801 }) {
790802 // The page revalidates while this is working, which reloads the runs too.
791− const data = useRuns(owner, repo, { number: String(number), limit: "5" });
803+ const data = useRuns(owner, repo, { number: String(number), limit: "5" }, initial);
792804 const [now, setNow] = useState(() => Date.now());
793805 useEffect(() => {
794806 const timer = setInterval(() => setNow(Date.now()), 5000);
+6−4
1515 import { Avatar, CopyLine, TimeAgo, notACredential } from "./ui";
1616 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
1717 import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
18+import { SkeletonLine } from "./ui/skeleton";
1819
1920 function encodePath(path: string): string {
2021 return path.split("/").map(encodeURIComponent).join("/");
238239 }) {
239240 const byName = new Map((last?.entries ?? []).map((entry) => [entry.name, entry.commit]));
240241 return (
241− <ul className="divide-y divide-line text-sm">
242+ // Busy while each entry's last commit is on its way; its cells keep their size.
243+ <ul aria-busy={last === undefined || undefined} className="divide-y divide-line text-sm">
242244 {entries.map((entry) => {
243245 const isTree = entry.kind === "tree";
244246 const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
256258 {commit ? (
257259 <Link
258260 to={`${base}/commit/${commit.hash}`}
259− className="block truncate text-muted hover:text-fg hover:underline"
261+ className="block animate-fade-in truncate text-muted hover:text-fg hover:underline"
260262 title={commit.message.split("\n")[0]}
261263 >
262264 {commit.message.split("\n")[0]}
263265 </Link>
264266 ) : last === undefined ? (
265− <span aria-hidden="true" className="block h-3 w-40 max-w-full animate-pulse rounded bg-raised" />
267+ <SkeletonLine width="10rem" barClassName="max-w-full" />
266268 ) : null}
267269 </span>
268270 <span className="text-right text-xs whitespace-nowrap text-faint">
269− {commit ? <TimeAgo at={commit.authoredAt} /> : last === undefined ? <span aria-hidden="true" className="inline-block h-3 w-12 animate-pulse rounded bg-raised" /> : null}
271+ {commit ? <span className="animate-fade-in"><TimeAgo at={commit.authoredAt} /></span> : last === undefined ? <SkeletonLine className="justify-end" barClassName="w-12" /> : null}
270272 </span>
271273 </li>
272274 );
+20−1
14401440 }
14411441
14421442 /**
1443+ * The page while the next one loads: after a moment it dims, under the
1444+ * bar below, so it is plainly on its way out rather than frozen, and the
1445+ * next page fades up from there. Sending a form does not dim it: the
1446+ * form's button says it is working. Spread on the element holding the page.
1447+ */
1448+export function useLeaving() {
1449+ const navigation = useNavigation();
1450+ const method = navigation.formMethod?.toUpperCase();
1451+ const leaving = navigation.state === "loading" && (method == null || method === "GET");
1452+ return {
1453+ "aria-busy": leaving || undefined,
1454+ className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`,
1455+ };
1456+}
1457+
1458+/**
14431459 * A bar across the top of the page while the next one loads, as GitHub
14441460 * has: it appears at once, creeps towards the end while waiting, then fills
14451461 * and fades when the page arrives.
15201536 else setTimeout(load, 1500);
15211537 }, [user]);
15221538 usePaletteShortcut(() => setPalette((open) => !open));
1539+ const leaving = useLeaving();
15231540
15241541 return (
15251542 <div className="min-h-screen">
16301647 </div>
16311648 </header>
16321649 {banner}
1633− <main className="min-w-0 grow">{children}</main>
1650+ <main {...leaving} className={`min-w-0 grow ${leaving.className}`}>
1651+ {children}
1652+ </main>
16341653 </div>
16351654 <CommandPalette
16361655 open={palette}
+151−3
1−import type { ComponentProps } from "react";
1+import type { ComponentProps, ReactNode } from "react";
22
33 import { cn } from "../../lib/cn";
44
55 // The shape of something still loading: a quiet raised block that pulses
66 // (still for people who ask for less motion). Give it the size of what it
77 // stands for, so nothing moves when the real thing arrives. Hidden from
8−// screen readers; the region it sits in says it is busy (`aria-busy`).
8+// screen readers; the region it sits in says it is busy (`aria-busy`),
9+// which `Loading` does, with a "Loading…" for them to hear.
10+//
11+// The pieces below are the app's common shapes, each the height of the
12+// real thing: compose a page's outline from them rather than drawing one.
913
1014 /** One block. Size it with classes: `h-3 w-24`, `size-8 rounded-full`. */
1115 export function Skeleton({ className, ...props }: ComponentProps<"span">) {
1923 }
2024
2125 /**
26+ * A bar standing in for one line of text, as tall as the line it sits in
27+ * (`1lh`), so a row keeps its height when the words arrive.
28+ */
29+export function SkeletonLine({ className, barClassName, width }: { className?: string; barClassName?: string; width?: string }) {
30+ return (
31+ <span aria-hidden="true" className={cn("flex h-[1lh] items-center", className)}>
32+ <Skeleton className={cn("h-3 w-full", barClassName)} style={width ? { width } : undefined} />
33+ </span>
34+ );
35+}
36+
37+/**
38+ * A region whose content is on its way: busy for assistive technology,
39+ * with "Loading…" said once. The skeleton inside is hidden from them.
40+ */
41+export function Loading({
42+ label = "Loading…",
43+ className,
44+ children,
45+ ...props
46+}: { label?: string; children: ReactNode } & ComponentProps<"div">) {
47+ return (
48+ <div aria-busy="true" className={className} {...props}>
49+ <span role="status" className="sr-only">
50+ {label}
51+ </span>
52+ {children}
53+ </div>
54+ );
55+}
56+
57+/**
2258 * Lines of text: `lines` bars `h-3` apart by `gap`, the last one shorter,
2359 * as a paragraph or a list's titles would sit.
2460 */
4278 {Array.from({ length: rows }, (_, index) => (
4379 <div key={index} className={cn("flex h-10 items-center gap-3 px-3", rowClassName)}>
4480 <Skeleton className="size-4 shrink-0 rounded-full" />
45− <Skeleton className="h-3 grow" style={{ maxWidth: `${70 - ((index * 17) % 30)}%` }} />
81+ <Skeleton className="h-3 grow" style={{ maxWidth: varied(index) }} />
4682 <Skeleton className="h-3 w-12 shrink-0" />
4783 </div>
4884 ))}
4985 </div>
5086 );
5187 }
88+
89+/** A width for row `index` that varies, so a list of bars does not look ruled. */
90+function varied(index: number, widest = 70, spread = 30) {
91+ return `${widest - ((index * 17) % spread)}%`;
92+}
93+
94+/**
95+ * Rows of a table: `columns` cells across, the first wide, the rest as
96+ * `cellClassName` sizes them, in rows as tall as text rows (`py-2`).
97+ */
98+export function SkeletonTableRows({
99+ rows = 3,
100+ columns = 3,
101+ className,
102+ rowClassName,
103+}: {
104+ rows?: number;
105+ columns?: number;
106+ className?: string;
107+ rowClassName?: string;
108+}) {
109+ return (
110+ <div aria-hidden="true" className={cn("divide-y divide-line text-sm", className)}>
111+ {Array.from({ length: rows }, (_, row) => (
112+ <div key={row} className={cn("flex items-center gap-4 px-4 py-2", rowClassName)}>
113+ <SkeletonLine className="grow" width={varied(row)} />
114+ {Array.from({ length: columns - 1 }, (_, column) => (
115+ <SkeletonLine key={column} className="w-16 shrink-0" />
116+ ))}
117+ </div>
118+ ))}
119+ </div>
120+ );
121+}
122+
123+/**
124+ * An item of an issue's or pull request's timeline (components/work.tsx
125+ * `TimelineItem`): the avatar beside a card with its header and `lines`
126+ * of body.
127+ */
128+export function SkeletonTimelineItem({ lines = 2, className }: { lines?: number; className?: string }) {
129+ return (
130+ <div aria-hidden="true" className={cn("flex gap-3", className)}>
131+ <Skeleton className="mt-1 hidden size-8 shrink-0 rounded-full sm:block" />
132+ <div className="min-w-0 grow overflow-hidden rounded-xl border border-line bg-surface">
133+ <div className="flex items-center gap-2 border-b border-line bg-raised/40 px-4 py-2 text-sm">
134+ <SkeletonLine className="w-20" />
135+ <SkeletonLine className="w-32" />
136+ </div>
137+ {lines > 0 && <SkeletonText lines={lines} className="px-4 py-4" />}
138+ </div>
139+ </div>
140+ );
141+}
142+
143+/** A card: a title and `lines` of text, in the app's bordered box. */
144+export function SkeletonCard({ lines = 2, className }: { lines?: number; className?: string }) {
145+ return (
146+ <div aria-hidden="true" className={cn("rounded-xl border border-line bg-surface p-4", className)}>
147+ <SkeletonLine className="w-1/3 text-sm" />
148+ {lines > 0 && <SkeletonText lines={lines} className="mt-3" />}
149+ </div>
150+ );
151+}
152+
153+/** A sidebar section: its small heading and `rows` lines under it. */
154+export function SkeletonSidebarSection({ rows = 2, className }: { rows?: number; className?: string }) {
155+ return (
156+ <div aria-hidden="true" className={cn("text-sm", className)}>
157+ <SkeletonLine className="w-24" />
158+ <div className="mt-2 space-y-1.5">
159+ {Array.from({ length: rows }, (_, index) => (
160+ <div key={index} className="flex items-center gap-2">
161+ <Skeleton className="size-5 shrink-0 rounded-full" />
162+ <SkeletonLine className="grow" barClassName="w-28" />
163+ </div>
164+ ))}
165+ </div>
166+ </div>
167+ );
168+}
169+
170+/**
171+ * A stat tile, as mission control's tiles sit in their grid: its label,
172+ * the number, and the line under it, each the height of its text.
173+ */
174+export function SkeletonStat({ className }: { className?: string }) {
175+ return (
176+ <div aria-hidden="true" className={cn("bg-surface px-4 py-3.5 sm:px-5 sm:py-4", className)}>
177+ <SkeletonLine className="w-16 text-xs" />
178+ <SkeletonLine className="mt-1 w-12 text-2xl sm:text-[1.75rem]" barClassName="h-6" />
179+ <SkeletonLine className="mt-0.5 w-24 text-xs" />
180+ </div>
181+ );
182+}
183+
184+/**
185+ * Lines of code, numbered, as `CodeLines` and a README's code blocks set
186+ * them: `lines` rows of the monospace line height.
187+ */
188+export function SkeletonCode({ lines = 6, className }: { lines?: number; className?: string }) {
189+ return (
190+ <div aria-hidden="true" className={cn("py-2 font-mono text-[0.8125rem] leading-6", className)}>
191+ {Array.from({ length: lines }, (_, index) => (
192+ <div key={index} className="flex items-center gap-4 px-4">
193+ <SkeletonLine className="w-6 shrink-0" />
194+ <SkeletonLine className="grow" width={varied(index, 80, 50)} />
195+ </div>
196+ ))}
197+ </div>
198+ );
199+}
+5−2
4646 DropdownMenuSeparator,
4747 DropdownMenuTrigger,
4848 } from "./components/ui/dropdown-menu";
49−import { AppShell, Progress, type ShellData } from "./components/shell";
49+import { AppShell, Progress, type ShellData, useLeaving } from "./components/shell";
5050 import { SiteFooter } from "./components/footer";
5151 import { SpikeBanner } from "./components/spike-banner";
5252 import { readCookie } from "./lib/mission";
469469 </SubmitButton>
470470 </Form>
471471 );
472+ const leaving = useLeaving();
472473 const banner =
473474 paused || verify ? (
474475 <>
498499 <Progress />
499500 <Header user={user} />
500501 {banner}
501− <div className="grow">{children}</div>
502+ <div {...leaving} className={`grow ${leaving.className}`}>
503+ {children}
504+ </div>
502505 <SiteFooter user={user} />
503506 </>
504507 )}
+21−11
5959 import { type NotStarted, chosenRepo, delegateForm, issuePath, notStarted } from "../lib/delegate";
6060 import { needsYou } from "../lib/inbox";
6161 import { Landing } from "../components/landing";
62−import { Skeleton, SkeletonRows } from "../components/ui/skeleton";
62+import { Skeleton, SkeletonCard, SkeletonLine, SkeletonRows, SkeletonStat } from "../components/ui/skeleton";
6363 import {
6464 agents,
6565 billing,
699699 function MissionControlSkeleton() {
700700 return (
701701 <main aria-busy="true" className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
702− <header>
703− <Skeleton className="h-8 w-72 sm:h-9" />
704− <Skeleton className="mt-2.5 h-3.5 w-96 max-w-full" />
702+ <span role="status" className="sr-only">
703+ Loading…
704+ </span>
705+ <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
706+ <div className="min-w-0 grow">
707+ <SkeletonLine className="w-72 max-w-full text-[1.75rem] leading-tight sm:text-[2rem]" barClassName="h-8" />
708+ <SkeletonLine className="mt-1.5 w-96 max-w-full text-sm" barClassName="h-3.5" />
709+ </div>
710+ <Skeleton className="h-9 w-56 rounded-md" />
705711 </header>
706712 <div className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-5">
707713 {Array.from({ length: 5 }, (_, index) => (
708− <div key={index} className="bg-surface p-4">
709− <Skeleton className="h-3 w-16" />
710− <Skeleton className="mt-3 h-6 w-10" />
711− <Skeleton className="mt-2 h-3 w-24" />
712− </div>
714+ <SkeletonStat key={index} className={index === 4 ? "col-span-2 lg:col-span-1" : undefined} />
713715 ))}
714716 </div>
715− <div className="rounded-xl border border-line bg-surface">
716− <SkeletonRows rows={5} rowClassName="h-14" />
717+ <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
718+ <div className="self-start overflow-hidden rounded-xl border border-line bg-surface">
719+ <div className="flex h-12 items-center gap-4 border-b border-line px-5">
720+ <Skeleton className="h-3.5 w-20" />
721+ <Skeleton className="h-3.5 w-28" />
722+ <Skeleton className="h-3.5 w-24" />
723+ </div>
724+ <SkeletonRows rows={5} rowClassName="h-16 px-5" />
725+ </div>
726+ <SkeletonCard lines={4} className="h-72 p-5" />
717727 </div>
718728 </main>
719729 );
+8−3
3737 import { notFound } from "../../lib/not-found.server";
3838 import { openedBy } from "../../lib/opened-by";
3939 import { computeNoteFor } from "../../lib/compute.server";
40−import { identity, inbox, integrations, work } from "../../lib/services.server";
40+import { agents, identity, inbox, integrations, work } from "../../lib/services.server";
4141 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
4242 import { accessTo, refusal, repoFor } from "../../lib/access.server";
4343 import { SubscriptionBox } from "../../components/notifications";
6767 // At once: only the plan's note waits for the viewer's role. Putting an
6868 // agent on it needs Write: Read cannot spend compute.
6969 const access = accessTo(context, params);
70− const [{ can }, found, labels, agentsEnabled, members, links, computeNote, subscription] = await Promise.all([
70+ const [{ can }, found, labels, agentsEnabled, members, links, computeNote, subscription, active] = await Promise.all([
7171 access,
7272 work.getIssue(path, number, viewer),
7373 work.listLabels(path, viewer),
8484 repo.ok ? inbox.subscription(viewer, { repoId: repo.value.id, number }).catch(() => null) : null,
8585 )
8686 : null,
87+ // The project's agents at work, for the line under the pull request
88+ // one is on for this issue: with the page, not after it.
89+ agents.listRuns(viewer, { repo: path, active: true, limit: 50 }).catch(() => null),
8790 ]);
8891 if (!found.ok) {
8992 // Issues and pull requests share numbers; this one may be a pull request.
100103 computeNote,
101104 links,
102105 subscription,
106+ // As the project's agents.json has them; left out, the line fetches them.
107+ activeRuns: active?.ok ? { runs: active.value, member: can.run } : undefined,
103108 members: members?.ok ? members.value.map((member) => member.username) : [],
104109 // The author can close and reopen their own issue, and whoever g1t's
105110 // agent filed one for, that one; Triage and up, anyone's.
428433 : "Seeing it through checks and review"}{" "}
429434 · #{assigned.number}
430435 </span>
431− <AgentStepLine owner={params.owner} repo={params.repo} number={assigned.number} />
436+ <AgentStepLine owner={params.owner} repo={params.repo} number={assigned.number} runs={loaderData.activeRuns} />
432437 </span>
433438 <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-accent" />
434439 </Link>
+34−13
4242 import { type ActiveBranch, ActiveBranches } from "../../components/branches";
4343 import { ProductionChecklist } from "../../components/checklist";
4444 import { PackageIcon } from "../../components/package-icon";
45−import { Skeleton, SkeletonRows } from "../../components/ui/skeleton";
45+import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton";
4646 import { ProductionShot } from "../../components/production-shot";
4747 import { GithubLinkStrip } from "../../components/github";
4848 import { githubApp } from "../../lib/github.server";
605605 return (
606606 <Suspense fallback={<OverviewSkeleton />}>
607607 <Await resolve={loaderData.overview} errorElement={<OverviewFailed />}>
608− {(overview) => <Overview loaderData={overview} actionData={actionData} params={params} />}
608+ {(overview) => (
609+ // Faded in over its outline, not switched for it.
610+ <div className="animate-fade-in">
611+ <Overview loaderData={overview} actionData={actionData} params={params} />
612+ </div>
613+ )}
609614 </Await>
610615 </Suspense>
611616 );
614619 /** The overview's sections as they will sit, while they stream in. */
615620 function OverviewSkeleton() {
616621 return (
617− <div className="space-y-8" aria-busy="true" aria-label="Loading the overview">
618− <section className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
619− <Skeleton className="h-3 w-24" />
620− <Skeleton className="mt-3 h-5 w-64 max-w-full" />
621− <Skeleton className="mt-3 h-3 w-48 max-w-full" />
622− </section>
623− <section className="rounded-2xl border border-line bg-surface p-2">
624− <div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
625− {Array.from({ length: 5 }, (_, index) => (
626− <Skeleton key={index} className="h-24 rounded-xl" />
622+ <Loading className="space-y-8" aria-label="Loading the overview">
623+ {/* Production: its screenshot beside where it is live, then the facts under them. */}
624+ <section aria-hidden="true" className="overflow-hidden rounded-2xl border border-line bg-surface">
625+ <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
626+ <Skeleton className="aspect-[16/10] w-full shrink-0 rounded-lg sm:w-72" />
627+ <div className="min-w-0 grow">
628+ <Skeleton className="h-3 w-24" />
629+ <Skeleton className="mt-3 h-5 w-72 max-w-full" />
630+ <Skeleton className="mt-3 h-3 w-48 max-w-full" />
631+ </div>
632+ </div>
633+ <div className="grid grid-cols-2 gap-px border-t border-line bg-line sm:grid-cols-4">
634+ {Array.from({ length: 4 }, (_, index) => (
635+ <div key={index} className="bg-surface px-5 py-4">
636+ <Skeleton className="h-3 w-16" />
637+ <Skeleton className="mt-2.5 h-4 w-28" />
638+ </div>
627639 ))}
628640 </div>
629641 </section>
642+ {/* Right now: the work, column by column. */}
643+ <div aria-hidden="true" className="grid grid-cols-2 gap-2 sm:grid-cols-5">
644+ {Array.from({ length: 5 }, (_, index) => (
645+ <div key={index} className="rounded-xl border border-line bg-surface p-3">
646+ <Skeleton className="h-3 w-20" />
647+ <Skeleton className="mt-3 h-14 rounded-lg" />
648+ </div>
649+ ))}
650+ </div>
630651 <div className="grid gap-6 lg:grid-cols-2">
631652 <section className="rounded-2xl border border-line bg-surface">
632653 <SkeletonRows rows={4} />
635656 <SkeletonRows rows={4} />
636657 </section>
637658 </div>
638− </div>
659+ </Loading>
639660 );
640661 }
641662
+29−8
5959 usePending,
6060 } from "../../components/ui";
6161 import { CheckboxOption } from "../../components/ui/checkbox";
62+import { Loading, SkeletonLine } from "../../components/ui/skeleton";
6263 import { WorkflowStatuses } from "../../components/actions";
6364 import { AddCiPrompt } from "../../components/add-ci";
6465 import {
7576 import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up";
7677 import { notFound } from "../../lib/not-found.server";
7778 import { computeNoteFor } from "../../lib/compute.server";
78−import { actions, deployments, identity, inbox, projects, repos, work } from "../../lib/services.server";
79+import { actions, agents, deployments, identity, inbox, projects, repos, work } from "../../lib/services.server";
7980 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
8081 import { accessTo, refusal, repoFor } from "../../lib/access.server";
8182 import { SubscriptionBox } from "../../components/notifications";
124125 const deps = projects.dependencies(params.owner, params.repo, viewer);
125126 // Awaited below, unless the pull request is missing first.
126127 deps.catch(() => null);
127− const [{ can }, found, repo, settings, agentsEnabled, members, computeNote, session, deployed, subscription] = await Promise.all([
128+ const [{ can }, found, repo, settings, agentsEnabled, members, computeNote, session, deployed, subscription, runs] = await Promise.all([
128129 access,
129130 pullFound,
130131 repos.get(path, viewer),
143144 found.ok ? inbox.subscription(viewer, { repoId: found.value.id, number }).catch(() => null) : null,
144145 )
145146 : null,
147+ // Its agent's latest runs, for the Agent panel: with the page, not after it.
148+ agents.listRuns(viewer, { repo: path, number, limit: 5 }).catch(() => null),
146149 ]);
147150 if (!found.ok) {
148151 // Issues and pull requests share numbers; this one may be an issue.
205208 agentsEnabled,
206209 computeNote,
207210 subscription,
211+ // As the project's agents.json has them; left out, the panel fetches them.
212+ agentRuns: runs?.ok ? { runs: runs.value, member: can.run } : undefined,
208213 members: members?.ok ? members.value.map((person) => person.username) : [],
209214 requireUpToDate: settings.ok && settings.value.requireUpToDate,
210215 mergeQueue: settings.ok && settings.value.mergeQueue,
537542 canIgnoreChecks,
538543 canUpdate,
539544 agentsEnabled,
545+ agentRuns,
540546 members,
541547 tab,
542548 session,
551557 const here = `${base}/pull/${pull.number}`;
552558 // g1t, on a pull request it made: the person who asked for it is a line below.
553559 const opener = openedBy(pull);
554− const checksSection = (jobs: Record<string, Job[]>) => (
560+ const checksSection = (jobs: Record<string, Job[]>, loading = false) => (
555561 <ChecksSection
556562 run={checks}
557563 required={requiredChecks}
563569 canRerunWorkflows={canMerge}
564570 settingsUrl={canProtect ? `${base}/settings/branches` : null}
565571 error={actionData?.action === "rerun-failed" || actionData?.action === "rerun-workflow" ? actionData.error : null}
572+ loading={loading}
566573 />
567574 );
568575 const addresses = useAddresses();
772779 number={pull.number}
773780 stage={lifecycle?.stage ?? null}
774781 confidence={pull.confidence ?? null}
782+ runs={agentRuns}
775783 />
776784
777785 {/* Steering: while its agent works, people can tell it things. */}
940948
941949 <CommentList comments={comments} review={review} base={base} />
942950
943− {(preview || build) && <DeploymentCard preview={preview} build={build} stacked={stacked} base={base} />}
951+ {(preview || build) && <DeploymentCard preview={preview} build={build} stacked={stacked} stacking={affects.length > 0} base={base} />}
944952
945953 {pull.status === "draft" && (
946954 <StatusBox>
963971 {pull.status === "open" && (
964972 <StatusBox>
965973 {/* Checks by status at once; job by job when the runs are read. */}
966− <Suspense fallback={checksSection({})}>
967− <Await resolve={workflowJobs}>{checksSection}</Await>
974+ <Suspense fallback={checksSection({}, true)}>
975+ <Await resolve={workflowJobs}>{(jobs) => checksSection(jobs)}</Await>
968976 </Suspense>
969977 {noChecks && <AddCiPrompt owner={params.owner} repo={params.repo} canAdd={canAddCi} compact />}
970978 <StatusRow
10121020 update={agentCatchUp.update}
10131021 startedAt={agentCatchUp.startedAt}
10141022 retrying={catchUpPending}
1023+ runs={agentRuns}
10151024 />
10161025 ) : conflicting ? (
10171026 <ConflictsSection
13651374 preview,
13661375 build,
13671376 stacked,
1377+ stacking,
13681378 base,
13691379 }: {
13701380 preview: LiveApp | null;
13711381 build: Deployment | null;
13721382 stacked: Promise<Stacked[]>;
1383+ /** Projects use this one, so previews built against it may be on their way. */
1384+ stacking: boolean;
13731385 base: string;
13741386 }) {
13751387 const building = build?.status === "queued" || build?.status === "building";
14261438 </p>
14271439 )}
14281440 {/* Read after the page: the card's own part shows at once. */}
1429− <Suspense fallback={null}>
1441+ <Suspense
1442+ fallback={
1443+ stacking && (
1444+ <Loading className="border-t border-line px-4 py-2.5 text-xs">
1445+ <SkeletonLine className="w-40" />
1446+ <SkeletonLine className="mt-1.5 w-64 max-w-full" />
1447+ </Loading>
1448+ )
1449+ }
1450+ >
14301451 <Await resolve={stacked}>
14311452 {(stacked) =>
14321453 stacked.length > 0 && (
1433− <div className="border-t border-line px-4 py-2.5">
1454+ <div className="animate-fade-in border-t border-line px-4 py-2.5">
14341455 <p className="text-xs text-muted">Built against this change:</p>
14351456 <ul className="mt-1.5 space-y-1">
14361457 {stacked.map((entry) => (
+14−4
88 import { CheckBadge } from "../../components/checks";
99 import { ChangeSize, PullIcon, StateTabs } from "../../components/work";
1010 import { AgentBadge, useActiveRuns } from "../../components/agents";
11−import { work } from "../../lib/services.server";
11+import { accessTo } from "../../lib/access.server";
12+import { agents, work } from "../../lib/services.server";
1213 import { getViewer, unwrap } from "../../lib/session.server";
1314
1415 export function meta({ params, ...args }: Route.MetaArgs) {
1718
1819 export async function loader({ params, context, request }: Route.LoaderArgs) {
1920 const path = { namespace: params.owner, name: params.repo };
21+ const viewer = getViewer(context);
2022 const state =
2123 new URL(request.url).searchParams.get("state") === "closed" ? "closed" : "open";
24+ // Which an agent is working on, beside the list, so its badges come with it.
25+ const [pulls, active, { can }] = await Promise.all([
26+ work.listPulls(path, viewer, state),
27+ agents.listRuns(viewer, { repo: path, active: true, limit: 100 }).catch(() => null),
28+ accessTo(context, params),
29+ ]);
2230 return {
23− pulls: unwrap(await work.listPulls(path, getViewer(context), state)),
31+ pulls: unwrap(pulls),
2432 state,
33+ // As the project's agents.json has them; left out, the list fetches them.
34+ active: active?.ok ? { runs: active.value, member: can.run } : undefined,
2535 } as const;
2636 }
2737
2838 export default function Pulls({ loaderData, params }: Route.ComponentProps) {
29− const { pulls, state } = loaderData;
39+ const { pulls, state, active } = loaderData;
3040 const base = `/${params.owner}/${params.repo}`;
3141 // Which pull requests an agent is working on this minute, and at what.
32− const working = useActiveRuns(params.owner, params.repo);
42+ const working = useActiveRuns(params.owner, params.repo, active);
3343 return (
3444 <div>
3545 <StateTabs