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.
| 304 | 304 | }; | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | − | type Live = { runs: AgentRun[]; member: boolean }; | |
| 307 | + | export type Live = { runs: AgentRun[]; member: boolean }; | |
| 308 | 308 | ||
| 309 | 309 | /** | |
| 310 | 310 | * 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. | |
| 312 | 314 | */ | |
| 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 { | |
| 314 | 321 | const fetcher = useFetcher<Live>(); | |
| 315 | 322 | const search = new URLSearchParams(query).toString(); | |
| 316 | 323 | const url = `/${owner}/${repo}/agents.json?${search}`; | |
| 317 | 324 | const { load } = fetcher; | |
| 325 | + | const loaded = initial !== undefined; | |
| 318 | 326 | 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; | |
| 322 | 340 | useEffect(() => { | |
| 323 | 341 | if (!live) return; | |
| 324 | 342 | const timer = setInterval(() => { | |
| 326 | 344 | }, LIVE_MS); | |
| 327 | 345 | return () => clearInterval(timer); | |
| 328 | 346 | }, [live, load, url]); | |
| 329 | − | return fetcher.data ?? null; | |
| 347 | + | return data; | |
| 330 | 348 | } | |
| 331 | 349 | ||
| 332 | 350 | /** "Agent confidence: Low — tests not added, 3 revisions", and what the agent said it was unsure of. */ | |
| 361 | 379 | number, | |
| 362 | 380 | stage, | |
| 363 | 381 | confidence, | |
| 382 | + | runs: initial, | |
| 364 | 383 | }: { | |
| 365 | 384 | owner: string; | |
| 366 | 385 | repo: string; | |
| 368 | 387 | stage?: Stage | null; | |
| 369 | 388 | /** How sure g1t is of the change, once the agent has finished it. */ | |
| 370 | 389 | confidence?: Confidence | null; | |
| 390 | + | /** Its latest five runs, from the page's loader, so the panel comes with the page. */ | |
| 391 | + | runs?: Live | null; | |
| 371 | 392 | }) { | |
| 372 | − | const data = useRuns(owner, repo, { number: String(number), limit: "5" }); | |
| 393 | + | const data = useRuns(owner, repo, { number: String(number), limit: "5" }, initial); | |
| 373 | 394 | const runs = data?.runs ?? []; | |
| 374 | 395 | const member = data?.member ?? false; | |
| 375 | 396 | const current = runs.find((run) => isActiveRun(run.status)) ?? runs[0]; | |
| 464 | 485 | } | |
| 465 | 486 | ||
| 466 | 487 | /** 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); | |
| 469 | 490 | return useMemo(() => { | |
| 470 | 491 | const byNumber = new Map<number, AgentRun>(); | |
| 471 | 492 | for (const run of data?.runs ?? []) { | |
| 490 | 511 | * What the agent on a pull request is doing this minute, as one line, for | |
| 491 | 512 | * an issue's sidebar: which agent picked the issue up, and where it is. | |
| 492 | 513 | */ | |
| 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)); | |
| 496 | 528 | if (!run?.step) return null; | |
| 497 | 529 | return ( | |
| 498 | 530 | <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={shownStep(run.step)}> |
| 11 | 11 | ||
| 12 | 12 | import { actionLabel, actorLabel, ruleLabel, targetLabel } from "../lib/audit"; | |
| 13 | 13 | import { Avatar, TimeAgo } from "./ui"; | |
| 14 | + | import { Loading, SkeletonRows } from "./ui/skeleton"; | |
| 14 | 15 | ||
| 15 | 16 | function clock(at: string): string { | |
| 16 | 17 | return new Date(at).toISOString().slice(11, 19); | |
| 168 | 169 | return () => clearInterval(timer); | |
| 169 | 170 | }, [live, load, url]); | |
| 170 | 171 | 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. | |
| 172 | 174 | return ( | |
| 173 | 175 | <details className="mt-3 group"> | |
| 174 | 176 | <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 | + | )} | |
| 176 | 184 | <Link to={`/${owner}/-/audit?project=${encodeURIComponent(repo)}&kind=agent`} className="mt-2 inline-block text-xs text-muted hover:text-fg"> | |
| 177 | 185 | Open the audit log | |
| 178 | 186 | </Link> |
| 27 | 27 | import { useAddresses } from "../lib/addresses"; | |
| 28 | 28 | import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up"; | |
| 29 | 29 | import { duration } from "./actions"; | |
| 30 | − | import { Elapsed, useRuns } from "./agents"; | |
| 30 | + | import { Elapsed, type Live, useRuns } from "./agents"; | |
| 31 | 31 | import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 32 | 32 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 33 | + | import { SkeletonLine } from "./ui/skeleton"; | |
| 33 | 34 | ||
| 34 | 35 | // --- Output --------------------------------------------------------------- | |
| 35 | 36 | ||
| 210 | 211 | name, | |
| 211 | 212 | detail, | |
| 212 | 213 | time, | |
| 214 | + | timing = false, | |
| 213 | 215 | to, | |
| 214 | 216 | }: { | |
| 215 | 217 | standing: Standing; | |
| 216 | 218 | name: React.ReactNode; | |
| 217 | 219 | detail?: React.ReactNode; | |
| 218 | 220 | time?: string; | |
| 221 | + | /** Its jobs and their times are still being read. */ | |
| 222 | + | timing?: boolean; | |
| 219 | 223 | to?: string; | |
| 220 | 224 | }) { | |
| 221 | 225 | return ( | |
| 225 | 229 | {name} | |
| 226 | 230 | {detail && <span className="text-muted"> — {detail}</span>} | |
| 227 | 231 | </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" />} | |
| 229 | 234 | {to && ( | |
| 230 | 235 | <Link to={to} className="shrink-0 text-xs text-muted hover:text-fg hover:underline"> | |
| 231 | 236 | Details | |
| 313 | 318 | canRerunWorkflows, | |
| 314 | 319 | settingsUrl, | |
| 315 | 320 | error, | |
| 321 | + | loading = false, | |
| 316 | 322 | }: { | |
| 317 | 323 | /** Set when the merge queue took it out, or for a run from before checks were workflows. */ | |
| 318 | 324 | run: CheckRun | null; | |
| 328 | 334 | /** Where the required checks are chosen, for those who may. */ | |
| 329 | 335 | settingsUrl?: string | null; | |
| 330 | 336 | error?: string | null; | |
| 337 | + | /** The workflow runs' jobs are still being read: their rows wait, busy. */ | |
| 338 | + | loading?: boolean; | |
| 331 | 339 | }) { | |
| 332 | 340 | if (!run && statuses.length === 0 && required.length === 0) return null; | |
| 333 | 341 | ||
| 379 | 387 | const failedRuns = statuses.filter((status) => statusStanding(status) === "failed" && runIdOf(status)); | |
| 380 | 388 | ||
| 381 | 389 | return ( | |
| 382 | − | <div> | |
| 390 | + | <div aria-busy={loading || undefined}> | |
| 383 | 391 | <div className="flex items-start gap-3 px-4 py-3"> | |
| 384 | 392 | <span className="mt-0.5 shrink-0"> | |
| 385 | 393 | {standing === "failed" || queueFailed ? ( | |
| 466 | 474 | standing={statusStanding(status)} | |
| 467 | 475 | name={status.context} | |
| 468 | 476 | detail={status.description ?? undefined} | |
| 477 | + | timing={loading && id != null} | |
| 469 | 478 | to={runPath} | |
| 470 | 479 | /> | |
| 471 | 480 | ); | |
| 775 | 784 | update, | |
| 776 | 785 | startedAt, | |
| 777 | 786 | retrying, | |
| 787 | + | runs: initial, | |
| 778 | 788 | }: { | |
| 779 | 789 | owner: string; | |
| 780 | 790 | repo: string; | |
| 786 | 796 | startedAt: number; | |
| 787 | 797 | /** Whether a retry is on its way. */ | |
| 788 | 798 | retrying: boolean; | |
| 799 | + | /** The pull request's latest runs, from the page's loader. */ | |
| 800 | + | runs?: Live | null; | |
| 789 | 801 | }) { | |
| 790 | 802 | // 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); | |
| 792 | 804 | const [now, setNow] = useState(() => Date.now()); | |
| 793 | 805 | useEffect(() => { | |
| 794 | 806 | const timer = setInterval(() => setNow(Date.now()), 5000); |
| 15 | 15 | import { Avatar, CopyLine, TimeAgo, notACredential } from "./ui"; | |
| 16 | 16 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 17 | 17 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 18 | + | import { SkeletonLine } from "./ui/skeleton"; | |
| 18 | 19 | ||
| 19 | 20 | function encodePath(path: string): string { | |
| 20 | 21 | return path.split("/").map(encodeURIComponent).join("/"); | |
| 238 | 239 | }) { | |
| 239 | 240 | const byName = new Map((last?.entries ?? []).map((entry) => [entry.name, entry.commit])); | |
| 240 | 241 | 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"> | |
| 242 | 244 | {entries.map((entry) => { | |
| 243 | 245 | const isTree = entry.kind === "tree"; | |
| 244 | 246 | const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File; | |
| 256 | 258 | {commit ? ( | |
| 257 | 259 | <Link | |
| 258 | 260 | 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" | |
| 260 | 262 | title={commit.message.split("\n")[0]} | |
| 261 | 263 | > | |
| 262 | 264 | {commit.message.split("\n")[0]} | |
| 263 | 265 | </Link> | |
| 264 | 266 | ) : 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" /> | |
| 266 | 268 | ) : null} | |
| 267 | 269 | </span> | |
| 268 | 270 | <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} | |
| 270 | 272 | </span> | |
| 271 | 273 | </li> | |
| 272 | 274 | ); |
| 1440 | 1440 | } | |
| 1441 | 1441 | ||
| 1442 | 1442 | /** | |
| 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 | + | /** | |
| 1443 | 1459 | * A bar across the top of the page while the next one loads, as GitHub | |
| 1444 | 1460 | * has: it appears at once, creeps towards the end while waiting, then fills | |
| 1445 | 1461 | * and fades when the page arrives. | |
| 1520 | 1536 | else setTimeout(load, 1500); | |
| 1521 | 1537 | }, [user]); | |
| 1522 | 1538 | usePaletteShortcut(() => setPalette((open) => !open)); | |
| 1539 | + | const leaving = useLeaving(); | |
| 1523 | 1540 | ||
| 1524 | 1541 | return ( | |
| 1525 | 1542 | <div className="min-h-screen"> | |
| 1630 | 1647 | </div> | |
| 1631 | 1648 | </header> | |
| 1632 | 1649 | {banner} | |
| 1633 | − | <main className="min-w-0 grow">{children}</main> | |
| 1650 | + | <main {...leaving} className={`min-w-0 grow ${leaving.className}`}> | |
| 1651 | + | {children} | |
| 1652 | + | </main> | |
| 1634 | 1653 | </div> | |
| 1635 | 1654 | <CommandPalette | |
| 1636 | 1655 | open={palette} |
| 1 | − | import type { ComponentProps } from "react"; | |
| 1 | + | import type { ComponentProps, ReactNode } from "react"; | |
| 2 | 2 | ||
| 3 | 3 | import { cn } from "../../lib/cn"; | |
| 4 | 4 | ||
| 5 | 5 | // The shape of something still loading: a quiet raised block that pulses | |
| 6 | 6 | // (still for people who ask for less motion). Give it the size of what it | |
| 7 | 7 | // 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. | |
| 9 | 13 | ||
| 10 | 14 | /** One block. Size it with classes: `h-3 w-24`, `size-8 rounded-full`. */ | |
| 11 | 15 | export function Skeleton({ className, ...props }: ComponentProps<"span">) { | |
| 19 | 23 | } | |
| 20 | 24 | ||
| 21 | 25 | /** | |
| 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 | + | /** | |
| 22 | 58 | * Lines of text: `lines` bars `h-3` apart by `gap`, the last one shorter, | |
| 23 | 59 | * as a paragraph or a list's titles would sit. | |
| 24 | 60 | */ | |
| 42 | 78 | {Array.from({ length: rows }, (_, index) => ( | |
| 43 | 79 | <div key={index} className={cn("flex h-10 items-center gap-3 px-3", rowClassName)}> | |
| 44 | 80 | <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) }} /> | |
| 46 | 82 | <Skeleton className="h-3 w-12 shrink-0" /> | |
| 47 | 83 | </div> | |
| 48 | 84 | ))} | |
| 49 | 85 | </div> | |
| 50 | 86 | ); | |
| 51 | 87 | } | |
| 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 | + | } |
| 46 | 46 | DropdownMenuSeparator, | |
| 47 | 47 | DropdownMenuTrigger, | |
| 48 | 48 | } from "./components/ui/dropdown-menu"; | |
| 49 | − | import { AppShell, Progress, type ShellData } from "./components/shell"; | |
| 49 | + | import { AppShell, Progress, type ShellData, useLeaving } from "./components/shell"; | |
| 50 | 50 | import { SiteFooter } from "./components/footer"; | |
| 51 | 51 | import { SpikeBanner } from "./components/spike-banner"; | |
| 52 | 52 | import { readCookie } from "./lib/mission"; | |
| 469 | 469 | </SubmitButton> | |
| 470 | 470 | </Form> | |
| 471 | 471 | ); | |
| 472 | + | const leaving = useLeaving(); | |
| 472 | 473 | const banner = | |
| 473 | 474 | paused || verify ? ( | |
| 474 | 475 | <> | |
| 498 | 499 | <Progress /> | |
| 499 | 500 | <Header user={user} /> | |
| 500 | 501 | {banner} | |
| 501 | − | <div className="grow">{children}</div> | |
| 502 | + | <div {...leaving} className={`grow ${leaving.className}`}> | |
| 503 | + | {children} | |
| 504 | + | </div> | |
| 502 | 505 | <SiteFooter user={user} /> | |
| 503 | 506 | </> | |
| 504 | 507 | )} |
| 59 | 59 | import { type NotStarted, chosenRepo, delegateForm, issuePath, notStarted } from "../lib/delegate"; | |
| 60 | 60 | import { needsYou } from "../lib/inbox"; | |
| 61 | 61 | import { Landing } from "../components/landing"; | |
| 62 | − | import { Skeleton, SkeletonRows } from "../components/ui/skeleton"; | |
| 62 | + | import { Skeleton, SkeletonCard, SkeletonLine, SkeletonRows, SkeletonStat } from "../components/ui/skeleton"; | |
| 63 | 63 | import { | |
| 64 | 64 | agents, | |
| 65 | 65 | billing, | |
| 699 | 699 | function MissionControlSkeleton() { | |
| 700 | 700 | return ( | |
| 701 | 701 | <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" /> | |
| 705 | 711 | </header> | |
| 706 | 712 | <div className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-5"> | |
| 707 | 713 | {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} /> | |
| 713 | 715 | ))} | |
| 714 | 716 | </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" /> | |
| 717 | 727 | </div> | |
| 718 | 728 | </main> | |
| 719 | 729 | ); |
| 37 | 37 | import { notFound } from "../../lib/not-found.server"; | |
| 38 | 38 | import { openedBy } from "../../lib/opened-by"; | |
| 39 | 39 | 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"; | |
| 41 | 41 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 42 | 42 | import { accessTo, refusal, repoFor } from "../../lib/access.server"; | |
| 43 | 43 | import { SubscriptionBox } from "../../components/notifications"; | |
| 67 | 67 | // At once: only the plan's note waits for the viewer's role. Putting an | |
| 68 | 68 | // agent on it needs Write: Read cannot spend compute. | |
| 69 | 69 | 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([ | |
| 71 | 71 | access, | |
| 72 | 72 | work.getIssue(path, number, viewer), | |
| 73 | 73 | work.listLabels(path, viewer), | |
| 84 | 84 | repo.ok ? inbox.subscription(viewer, { repoId: repo.value.id, number }).catch(() => null) : null, | |
| 85 | 85 | ) | |
| 86 | 86 | : 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), | |
| 87 | 90 | ]); | |
| 88 | 91 | if (!found.ok) { | |
| 89 | 92 | // Issues and pull requests share numbers; this one may be a pull request. | |
| 100 | 103 | computeNote, | |
| 101 | 104 | links, | |
| 102 | 105 | 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, | |
| 103 | 108 | members: members?.ok ? members.value.map((member) => member.username) : [], | |
| 104 | 109 | // The author can close and reopen their own issue, and whoever g1t's | |
| 105 | 110 | // agent filed one for, that one; Triage and up, anyone's. | |
| 428 | 433 | : "Seeing it through checks and review"}{" "} | |
| 429 | 434 | · #{assigned.number} | |
| 430 | 435 | </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} /> | |
| 432 | 437 | </span> | |
| 433 | 438 | <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-accent" /> | |
| 434 | 439 | </Link> |
| 42 | 42 | import { type ActiveBranch, ActiveBranches } from "../../components/branches"; | |
| 43 | 43 | import { ProductionChecklist } from "../../components/checklist"; | |
| 44 | 44 | import { PackageIcon } from "../../components/package-icon"; | |
| 45 | − | import { Skeleton, SkeletonRows } from "../../components/ui/skeleton"; | |
| 45 | + | import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton"; | |
| 46 | 46 | import { ProductionShot } from "../../components/production-shot"; | |
| 47 | 47 | import { GithubLinkStrip } from "../../components/github"; | |
| 48 | 48 | import { githubApp } from "../../lib/github.server"; | |
| 605 | 605 | return ( | |
| 606 | 606 | <Suspense fallback={<OverviewSkeleton />}> | |
| 607 | 607 | <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 | + | )} | |
| 609 | 614 | </Await> | |
| 610 | 615 | </Suspense> | |
| 611 | 616 | ); | |
| 614 | 619 | /** The overview's sections as they will sit, while they stream in. */ | |
| 615 | 620 | function OverviewSkeleton() { | |
| 616 | 621 | 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> | |
| 627 | 639 | ))} | |
| 628 | 640 | </div> | |
| 629 | 641 | </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> | |
| 630 | 651 | <div className="grid gap-6 lg:grid-cols-2"> | |
| 631 | 652 | <section className="rounded-2xl border border-line bg-surface"> | |
| 632 | 653 | <SkeletonRows rows={4} /> | |
| 635 | 656 | <SkeletonRows rows={4} /> | |
| 636 | 657 | </section> | |
| 637 | 658 | </div> | |
| 638 | − | </div> | |
| 659 | + | </Loading> | |
| 639 | 660 | ); | |
| 640 | 661 | } | |
| 641 | 662 |
| 59 | 59 | usePending, | |
| 60 | 60 | } from "../../components/ui"; | |
| 61 | 61 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 62 | + | import { Loading, SkeletonLine } from "../../components/ui/skeleton"; | |
| 62 | 63 | import { WorkflowStatuses } from "../../components/actions"; | |
| 63 | 64 | import { AddCiPrompt } from "../../components/add-ci"; | |
| 64 | 65 | import { | |
| 75 | 76 | import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up"; | |
| 76 | 77 | import { notFound } from "../../lib/not-found.server"; | |
| 77 | 78 | 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"; | |
| 79 | 80 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 80 | 81 | import { accessTo, refusal, repoFor } from "../../lib/access.server"; | |
| 81 | 82 | import { SubscriptionBox } from "../../components/notifications"; | |
| 124 | 125 | const deps = projects.dependencies(params.owner, params.repo, viewer); | |
| 125 | 126 | // Awaited below, unless the pull request is missing first. | |
| 126 | 127 | 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([ | |
| 128 | 129 | access, | |
| 129 | 130 | pullFound, | |
| 130 | 131 | repos.get(path, viewer), | |
| 143 | 144 | found.ok ? inbox.subscription(viewer, { repoId: found.value.id, number }).catch(() => null) : null, | |
| 144 | 145 | ) | |
| 145 | 146 | : 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), | |
| 146 | 149 | ]); | |
| 147 | 150 | if (!found.ok) { | |
| 148 | 151 | // Issues and pull requests share numbers; this one may be an issue. | |
| 205 | 208 | agentsEnabled, | |
| 206 | 209 | computeNote, | |
| 207 | 210 | 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, | |
| 208 | 213 | members: members?.ok ? members.value.map((person) => person.username) : [], | |
| 209 | 214 | requireUpToDate: settings.ok && settings.value.requireUpToDate, | |
| 210 | 215 | mergeQueue: settings.ok && settings.value.mergeQueue, | |
| 537 | 542 | canIgnoreChecks, | |
| 538 | 543 | canUpdate, | |
| 539 | 544 | agentsEnabled, | |
| 545 | + | agentRuns, | |
| 540 | 546 | members, | |
| 541 | 547 | tab, | |
| 542 | 548 | session, | |
| 551 | 557 | const here = `${base}/pull/${pull.number}`; | |
| 552 | 558 | // g1t, on a pull request it made: the person who asked for it is a line below. | |
| 553 | 559 | const opener = openedBy(pull); | |
| 554 | − | const checksSection = (jobs: Record<string, Job[]>) => ( | |
| 560 | + | const checksSection = (jobs: Record<string, Job[]>, loading = false) => ( | |
| 555 | 561 | <ChecksSection | |
| 556 | 562 | run={checks} | |
| 557 | 563 | required={requiredChecks} | |
| 563 | 569 | canRerunWorkflows={canMerge} | |
| 564 | 570 | settingsUrl={canProtect ? `${base}/settings/branches` : null} | |
| 565 | 571 | error={actionData?.action === "rerun-failed" || actionData?.action === "rerun-workflow" ? actionData.error : null} | |
| 572 | + | loading={loading} | |
| 566 | 573 | /> | |
| 567 | 574 | ); | |
| 568 | 575 | const addresses = useAddresses(); | |
| 772 | 779 | number={pull.number} | |
| 773 | 780 | stage={lifecycle?.stage ?? null} | |
| 774 | 781 | confidence={pull.confidence ?? null} | |
| 782 | + | runs={agentRuns} | |
| 775 | 783 | /> | |
| 776 | 784 | ||
| 777 | 785 | {/* Steering: while its agent works, people can tell it things. */} | |
| 940 | 948 | ||
| 941 | 949 | <CommentList comments={comments} review={review} base={base} /> | |
| 942 | 950 | ||
| 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} />} | |
| 944 | 952 | ||
| 945 | 953 | {pull.status === "draft" && ( | |
| 946 | 954 | <StatusBox> | |
| 963 | 971 | {pull.status === "open" && ( | |
| 964 | 972 | <StatusBox> | |
| 965 | 973 | {/* 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> | |
| 968 | 976 | </Suspense> | |
| 969 | 977 | {noChecks && <AddCiPrompt owner={params.owner} repo={params.repo} canAdd={canAddCi} compact />} | |
| 970 | 978 | <StatusRow | |
| 1012 | 1020 | update={agentCatchUp.update} | |
| 1013 | 1021 | startedAt={agentCatchUp.startedAt} | |
| 1014 | 1022 | retrying={catchUpPending} | |
| 1023 | + | runs={agentRuns} | |
| 1015 | 1024 | /> | |
| 1016 | 1025 | ) : conflicting ? ( | |
| 1017 | 1026 | <ConflictsSection | |
| 1365 | 1374 | preview, | |
| 1366 | 1375 | build, | |
| 1367 | 1376 | stacked, | |
| 1377 | + | stacking, | |
| 1368 | 1378 | base, | |
| 1369 | 1379 | }: { | |
| 1370 | 1380 | preview: LiveApp | null; | |
| 1371 | 1381 | build: Deployment | null; | |
| 1372 | 1382 | stacked: Promise<Stacked[]>; | |
| 1383 | + | /** Projects use this one, so previews built against it may be on their way. */ | |
| 1384 | + | stacking: boolean; | |
| 1373 | 1385 | base: string; | |
| 1374 | 1386 | }) { | |
| 1375 | 1387 | const building = build?.status === "queued" || build?.status === "building"; | |
| 1426 | 1438 | </p> | |
| 1427 | 1439 | )} | |
| 1428 | 1440 | {/* 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 | + | > | |
| 1430 | 1451 | <Await resolve={stacked}> | |
| 1431 | 1452 | {(stacked) => | |
| 1432 | 1453 | 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"> | |
| 1434 | 1455 | <p className="text-xs text-muted">Built against this change:</p> | |
| 1435 | 1456 | <ul className="mt-1.5 space-y-1"> | |
| 1436 | 1457 | {stacked.map((entry) => ( |
| 8 | 8 | import { CheckBadge } from "../../components/checks"; | |
| 9 | 9 | import { ChangeSize, PullIcon, StateTabs } from "../../components/work"; | |
| 10 | 10 | 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"; | |
| 12 | 13 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 13 | 14 | ||
| 14 | 15 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 17 | 18 | ||
| 18 | 19 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 19 | 20 | const path = { namespace: params.owner, name: params.repo }; | |
| 21 | + | const viewer = getViewer(context); | |
| 20 | 22 | const state = | |
| 21 | 23 | 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 | + | ]); | |
| 22 | 30 | return { | |
| 23 | − | pulls: unwrap(await work.listPulls(path, getViewer(context), state)), | |
| 31 | + | pulls: unwrap(pulls), | |
| 24 | 32 | 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, | |
| 25 | 35 | } as const; | |
| 26 | 36 | } | |
| 27 | 37 | ||
| 28 | 38 | export default function Pulls({ loaderData, params }: Route.ComponentProps) { | |
| 29 | − | const { pulls, state } = loaderData; | |
| 39 | + | const { pulls, state, active } = loaderData; | |
| 30 | 40 | const base = `/${params.owner}/${params.repo}`; | |
| 31 | 41 | // 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); | |
| 33 | 43 | return ( | |
| 34 | 44 | <div> | |
| 35 | 45 | <StateTabs |