Skip to content

Commit

Merge branch 'worktree-agent-aca4790ea223aa235'

syntaqxcommitted Parentsb1f4635d2d4c53Browse 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
15391539 }
15401540
15411541 /**
1542+ * The page while the next one loads: after a moment it dims, under the
1543+ * bar below, so it is plainly on its way out rather than frozen, and the
1544+ * next page fades up from there. Sending a form does not dim it: the
1545+ * form's button says it is working. Spread on the element holding the page.
1546+ */
1547+export function useLeaving() {
1548+ const navigation = useNavigation();
1549+ const method = navigation.formMethod?.toUpperCase();
1550+ const leaving = navigation.state === "loading" && (method == null || method === "GET");
1551+ return {
1552+ "aria-busy": leaving || undefined,
1553+ className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`,
1554+ };
1555+}
1556+
1557+/**
15421558 * A bar across the top of the page while the next one loads, as GitHub
15431559 * has: it appears at once, creeps towards the end while waiting, then fills
15441560 * and fades when the page arrives.
16191635 else setTimeout(load, 1500);
16201636 }, [user]);
16211637 usePaletteShortcut(() => setPalette((open) => !open));
1638+ const leaving = useLeaving();
16221639
16231640 return (
16241641 <div className="min-h-screen">
17411758 </div>
17421759 </header>
17431760 {banner}
1744− <main className="min-w-0 grow">{children}</main>
1761+ <main {...leaving} className={`min-w-0 grow ${leaving.className}`}>
1762+ {children}
1763+ </main>
17451764 </div>
17461765 <CommandPalette
17471766 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