Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

661 linesCodeBlameRaw
1/**
2 * Home's parts (routes/workspace/home.tsx): Where you're needed, Start
3 * here, what happened in the span (what people did, what landed, builds,
4 * spend) and Running now. Each takes what
5 * lib/home.ts worked out and only draws it.
6 */
7import {
8 AlertTriangle,
9 ArrowRight,
10 AtSign,
11 Bell,
12 GitCommitHorizontal,
13 GitMerge,
14 GitPullRequest,
15 Loader,
16 Mail,
17 Package,
18 Rocket,
19 Server,
20 Tag,
21 User,
22 Users,
23 Workflow,
24} from "lucide-react";
25import type { ReactNode } from "react";
26import { Link } from "react-router";
27
28import {
29 type Attention,
30 type AttentionKind,
31 type AttentionRow,
32 type DeployRow,
33 type Digest,
34 type DigestActor,
35 type DigestColumn,
36 type LandedChange,
37 type RunningRow,
38 type Span,
39 type Spend,
40 type Stake,
41 WINDOWS,
42 listOf,
43 waited,
44 whenShort,
45} from "../lib/home";
46import { cn } from "../lib/cn";
47import { money } from "../lib/money";
48import { ButtonLink } from "./ui";
49import { Avatar } from "./ui/avatar";
50import { Badge } from "./ui/badge";
51import { Skeleton } from "./ui/skeleton";
52import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
53
54export function Card({
55 title,
56 hint,
57 link,
58 children,
59 className = "",
60}: {
61 title: ReactNode;
62 /** A few words under the title. */
63 hint?: string;
64 link?: { label: string; to: string } | null;
65 children: ReactNode;
66 className?: string;
67}) {
68 return (
69 <section className={`rounded-2xl border border-line bg-surface ${className}`}>
70 <header className="flex items-start gap-2 px-5 pt-4 pb-3">
71 <div className="min-w-0">
72 <h2 className="text-sm font-semibold text-fg">{title}</h2>
73 {hint && <p className="mt-0.5 text-xs text-muted">{hint}</p>}
74 </div>
75 {link && (
76 <Link to={link.to} className="ml-auto inline-flex shrink-0 items-center gap-1 pt-0.5 text-xs text-muted hover:text-fg">
77 {link.label}
78 <ArrowRight size={12} />
79 </Link>
80 )}
81 </header>
82 {children}
83 </section>
84 );
85}
86
87export function Quiet({ children }: { children: ReactNode }) {
88 return <p className="px-5 pb-5 text-sm text-muted">{children}</p>;
89}
90
91function Missing({ names, what }: { names: string[]; what: string }) {
92 if (names.length === 0) return null;
93 return (
94 <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint">
95 <AlertTriangle size={12} className="shrink-0" />
96 {listOf(names)} didn&apos;t answer, so {what} isn&apos;t listed.
97 </p>
98 );
99}
100
101// --- The window switch ----------------------------------------------------------
102
103/** Since last visit, last 24 hours, last 7 days: links, so the span is in the address and works without script. */
104export function WindowSwitch({ span }: { span: Pick<Span, "key"> }) {
105 return (
106 <nav aria-label="What happened over" className="flex shrink-0 self-start rounded-lg border border-line bg-surface p-0.5">
107 {WINDOWS.map((option) => {
108 const current = option.key === span.key;
109 return (
110 <Link
111 key={option.key}
112 to={option.key === "last" ? "?" : `?window=${option.key}`}
113 preventScrollReset
114 aria-current={current ? "true" : undefined}
115 className={cn(
116 "rounded-md px-2.5 py-1 text-xs font-medium whitespace-nowrap transition-colors",
117 current ? "bg-raised text-fg shadow-[0_0_0_1px_var(--color-line-strong)]" : "text-muted hover:text-fg",
118 )}
119 >
120 {option.label}
121 </Link>
122 );
123 })}
124 </nav>
125 );
126}
127
128// --- Where you're needed ---------------------------------------------------------
129
130const ICON: Record<AttentionKind, ReactNode> = {
131 deploy: <Rocket size={16} className="text-danger" />,
132 pull_blocked: <GitPullRequest size={16} className="text-warn" />,
133 review: <GitPullRequest size={16} className="text-info" />,
134 invitation: <Mail size={16} className="text-info" />,
135 ready: <GitMerge size={16} className="text-success" />,
136 runner: <Server size={16} className="text-warn" />,
137 notification: <Bell size={16} className="text-warn" />,
138 chat: <AtSign size={16} className="text-accent" />,
139};
140
141const STAKE_TONE: Record<NonNullable<Stake["tone"]> | "none", string> = {
142 danger: "text-danger",
143 warn: "text-warn",
144 accent: "text-accent",
145 none: "text-fg-soft",
146};
147
148function StakeText({ stake }: { stake: Stake }) {
149 return <span className={`text-xs font-medium tabular-nums ${STAKE_TONE[stake.tone ?? "none"]}`}>{stake.text}</span>;
150}
151
152/** Rows shown before the rest fold away. */
153const ROWS = 8;
154
155function NeedItem({ row, now }: { row: AttentionRow; now: number }) {
156 return (
157 <li className="flex items-start gap-3 border-t border-line px-5 py-3 first:border-t-0">
158 <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span>
159 <div className="min-w-0 grow">
160 <Link to={row.action.to} className="line-clamp-2 text-sm font-semibold text-fg hover:underline sm:block sm:truncate">
161 {row.title}
162 </Link>
163 <p className="truncate text-xs text-muted">{row.detail}</p>
164 <div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs sm:hidden">
165 {row.stake && <StakeText stake={row.stake} />}
166 {row.at > 0 && (
167 <span className="text-faint" suppressHydrationWarning>
168 waiting {waited(row.at, now)}
169 </span>
170 )}
171 </div>
172 </div>
173 <div className="hidden max-w-48 shrink-0 pt-0.5 text-right whitespace-nowrap sm:block">
174 {row.stake && <StakeText stake={row.stake} />}
175 {row.at > 0 && (
176 <div className="text-[0.6875rem] text-faint tabular-nums" suppressHydrationWarning>
177 waiting {waited(row.at, now)}
178 </div>
179 )}
180 </div>
181 <ButtonLink to={row.action.to} variant="outline" size="xs" className="border-line-strong px-2.5 text-fg hover:border-accent/50 hover:bg-raised">
182 {row.action.label}
183 </ButtonLink>
184 </li>
185 );
186}
187
188/** Everything waiting on you, most pressing and longest waiting first; past the first few, the rest fold away. */
189export function NeedsList({ attention, now }: { attention: Attention; now: number }) {
190 const { rows, missing } = attention;
191 return (
192 <>
193 {rows.length === 0 ? (
194 <Quiet>{missing.length > 0 ? "Nothing needs you in what could be read." : "Nothing needs you. Reviews, invitations and messages for you show here as soon as they arrive."}</Quiet>
195 ) : (
196 <ul>
197 {rows.slice(0, ROWS).map((row) => (
198 <NeedItem key={row.key} row={row} now={now} />
199 ))}
200 </ul>
201 )}
202 {rows.length > ROWS && (
203 <details className="group border-t border-line">
204 <summary className="cursor-pointer list-none px-5 py-2.5 text-xs font-medium text-fg-soft hover:text-fg">
205 <span className="group-open:hidden">Show {(rows.length - ROWS).toLocaleString("en-US")} more</span>
206 <span className="hidden group-open:inline">Show fewer</span>
207 </summary>
208 <ul className="border-t border-line">
209 {rows.slice(ROWS).map((row) => (
210 <NeedItem key={row.key} row={row} now={now} />
211 ))}
212 </ul>
213 </details>
214 )}
215 <Missing names={missing} what="what waits there" />
216 </>
217 );
218}
219
220export function RowsSkeleton({ rows = 3 }: { rows?: number }) {
221 return (
222 <div className="space-y-4 px-5 pb-5" aria-busy="true">
223 {Array.from({ length: rows }, (_, i) => (
224 <div key={i} className="flex items-center gap-3">
225 <Skeleton className="size-4 rounded" />
226 <div className="grow space-y-1.5">
227 <Skeleton className="h-3.5" style={{ width: `${60 - i * 8}%` }} />
228 <Skeleton className="h-3" style={{ width: `${80 - i * 10}%` }} />
229 </div>
230 <Skeleton className="h-7 w-16" />
231 </div>
232 ))}
233 </div>
234 );
235}
236
237// --- Start here -----------------------------------------------------------------
238
239export function StartHere({ attention, now }: { attention: Attention; now: number }) {
240 const start = attention.start;
241 return (
242 <Card
243 title={
244 <>
245 Start here <span className="font-normal text-faint">· picked by g1t</span>
246 </>
247 }
248 >
249 {!start ? (
250 <Quiet>{attention.missing.length > 0 ? "Nothing to pick from what could be read." : "Nothing needs you, so there's nothing to pick."}</Quiet>
251 ) : (
252 <div className="px-5 pb-5">
253 <div className="flex items-start gap-3">
254 <span className="mt-0.5 shrink-0">{ICON[start.row.kind]}</span>
255 <div className="min-w-0">
256 <p className="text-[0.9375rem] leading-snug font-semibold text-fg">{start.row.title}</p>
257 <p className="mt-1 line-clamp-2 text-xs text-muted">{start.row.detail}</p>
258 </div>
259 </div>
260 <dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
261 {start.row.owner && (
262 <>
263 <dt className="text-muted">Owner</dt>
264 <dd className="flex min-w-0 items-center gap-1.5 text-fg-soft">
265 <User size={12} className="shrink-0 text-muted" />
266 <span className="truncate">{start.row.owner.agent ? `@${start.row.owner.name}` : start.row.owner.name}</span>
267 </dd>
268 </>
269 )}
270 <dt className="text-muted">Next</dt>
271 <dd className="text-fg-soft">{start.row.action.label}</dd>
272 {start.row.at > 0 && (
273 <>
274 <dt className="text-muted">Waiting</dt>
275 <dd className="text-fg-soft" suppressHydrationWarning>
276 {waited(start.row.at, now)}
277 </dd>
278 </>
279 )}
280 {start.row.stake && (
281 <>
282 <dt className="text-muted">At stake</dt>
283 <dd>
284 <StakeText stake={start.row.stake} />
285 </dd>
286 </>
287 )}
288 </dl>
289 <p className="mt-4 rounded-lg bg-raised/60 px-3 py-2.5 text-xs leading-relaxed text-fg-soft">
290 <span className="font-medium text-fg">Why this one: </span>
291 {start.why}
292 </p>
293 <Link
294 to={start.row.action.to}
295 className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover"
296 >
297 {start.row.action.label}
298 <ArrowRight size={14} />
299 </Link>
300 </div>
301 )}
302 </Card>
303 );
304}
305
306// --- What people did in the span ------------------------------------------------
307
308/** "in the span" said the way the span is: "since you were last here", "in the last 24 hours". */
309function inSpan(span: Pick<Span, "key" | "note" | "words">): string {
310 return span.key === "last" && span.note == null ? "since you were last here" : span.words.charAt(0).toLowerCase() + span.words.slice(1);
311}
312
313/** The faces of the most active, with a tooltip naming each, and how many more there were. */
314const FACES = 5;
315
316function Faces({ actors }: { actors: DigestActor[] }) {
317 if (actors.length === 0) return null;
318 const shown = actors.slice(0, FACES);
319 const more = actors.length - shown.length;
320 return (
321 <div className="flex items-center gap-1.5">
322 <ul className="flex -space-x-1.5" aria-label="Most active">
323 {shown.map((actor) => (
324 <li key={actor.key} className="rounded-full ring-2 ring-surface">
325 <Tooltip>
326 <TooltipTrigger asChild>
327 <span className="block">
328 <Avatar name={actor.name} size={22} />
329 </span>
330 </TooltipTrigger>
331 <TooltipContent>
332 {actor.name} · {actor.count.toLocaleString("en-US")} {actor.count === 1 ? "thing" : "things"}
333 </TooltipContent>
334 </Tooltip>
335 </li>
336 ))}
337 </ul>
338 {more > 0 && <span className="text-xs text-muted">+{more.toLocaleString("en-US")}</span>}
339 </div>
340 );
341}
342
343/** The lines: each what was done, with its count in the words, linked to where it is listed. */
344function Lines({ column }: { column: DigestColumn }) {
345 return (
346 <ul className="mt-3 space-y-1.5">
347 {column.lines.map((line) => (
348 <li key={line.key} className="flex items-start gap-2 text-sm text-fg">
349 <span className="mt-1.75 size-1.5 shrink-0 rounded-full bg-fg-soft/60" aria-hidden />
350 {line.to ? (
351 <Link to={line.to} className="hover:underline">
352 {line.text}
353 </Link>
354 ) : (
355 <span>{line.text}</span>
356 )}
357 </li>
358 ))}
359 </ul>
360 );
361}
362
363/**
364 * What people did in the span: lines with counts linked to where each
365 * thing is listed, and the most active faces. It says when it is empty,
366 * and which part didn't answer.
367 */
368export function PeopleCard({ digest, span, code }: { digest: Digest | null; span: Pick<Span, "key" | "note" | "words">; code: boolean }) {
369 const when = inSpan(span);
370 if (!digest) {
371 return (
372 <section className="rounded-2xl border border-line bg-surface p-5">
373 <p className="text-sm text-muted">What happened couldn&apos;t be read right now. It shows here once Code and Chat answer.</p>
374 </section>
375 );
376 }
377 const silent = digest.missing.filter((name) => name === "Code" || name === "Chat");
378 const col = digest.people;
379 return (
380 <section className="rounded-2xl border border-line bg-surface p-5" aria-label="What people did">
381 <div className="flex flex-wrap items-center gap-3">
382 <h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-fg">
383 <Users size={15} className="text-muted" />
384 People
385 </h3>
386 {col.actors.length > 0 && (
387 <Badge tone="neutral" size="md" className="tabular-nums">
388 {col.actors.length.toLocaleString("en-US")} active
389 </Badge>
390 )}
391 <span className="ml-auto">
392 <Faces actors={col.actors} />
393 </span>
394 </div>
395 {col.lines.length > 0 ? (
396 <Lines column={col} />
397 ) : (
398 <p className="mt-3 text-sm text-muted">
399 {silent.length > 0
400 ? `Nothing from what could be read ${when}.`
401 : code
402 ? `Nobody pushed, opened, merged, reviewed, edited a doc or said anything ${when}.`
403 : `Nobody edited a doc or said anything ${when}.`}
404 </p>
405 )}
406 {silent.length > 0 && (
407 <p className="mt-3 flex items-center gap-1.5 text-xs text-faint">
408 <AlertTriangle size={12} className="shrink-0" />
409 {listOf(silent)} didn&apos;t answer, so {silent.length === 1 ? "its part" : "their parts"} {silent.length === 1 ? "isn't" : "aren't"} counted.
410 </p>
411 )}
412 {digest.partial && <p className="mt-3 text-xs text-faint">More happened than Home reads at once, so some counts are low.</p>}
413 </section>
414 );
415}
416
417export function SpanSkeleton() {
418 return (
419 <section className="space-y-3 rounded-2xl border border-line bg-surface p-5" aria-busy="true">
420 <div className="flex items-center justify-between">
421 <Skeleton className="h-3.5 w-20" />
422 <Skeleton className="h-5 w-24 rounded-full" />
423 </div>
424 <Skeleton className="h-3.5 w-3/4" />
425 <Skeleton className="h-3.5 w-1/2" />
426 <Skeleton className="h-3.5 w-2/3" />
427 </section>
428 );
429}
430
431// --- Landed and builds -----------------------------------------------------------
432
433/** Rows each list shows before the rest fold away. */
434const LIST_ROWS = 5;
435
436function When({ at, now, tz }: { at: number; now: number; tz: string | null }) {
437 return (
438 <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning>
439 {whenShort(at, now, tz)}
440 </span>
441 );
442}
443
444function Folded<T>({ items, render }: { items: T[]; render: (item: T) => ReactNode }) {
445 return (
446 <>
447 <ul>{items.slice(0, LIST_ROWS).map(render)}</ul>
448 {items.length > LIST_ROWS && (
449 <details className="group border-t border-line">
450 <summary className="cursor-pointer list-none px-5 py-2.5 text-xs font-medium text-fg-soft hover:text-fg">
451 <span className="group-open:hidden">Show {(items.length - LIST_ROWS).toLocaleString("en-US")} more</span>
452 <span className="hidden group-open:inline">Show fewer</span>
453 </summary>
454 <ul className="border-t border-line">{items.slice(LIST_ROWS).map(render)}</ul>
455 </details>
456 )}
457 </>
458 );
459}
460
461const rowClass = "flex items-center gap-3 border-t border-line px-5 py-2.5 first:border-t-0";
462
463const LANDED_ICON: Record<LandedChange["kind"], ReactNode> = {
464 merge: <GitMerge size={15} className="shrink-0 text-success" />,
465 push: <GitCommitHorizontal size={15} className="shrink-0 text-success" />,
466 deploy: <Rocket size={15} className="shrink-0 text-success" />,
467 release: <Tag size={15} className="shrink-0 text-info" />,
468 package: <Package size={15} className="shrink-0 text-info" />,
469};
470
471/** A few words on the card's line: how many of each kind landed. */
472function landedHint(landed: LandedChange[]): string {
473 const count = (kind: LandedChange["kind"]) => landed.filter((change) => change.kind === kind).length;
474 const parts: string[] = [];
475 const merged = count("merge");
476 const pushes = count("push");
477 const deploys = count("deploy");
478 const published = count("release") + count("package");
479 if (merged > 0) parts.push(`${merged.toLocaleString("en-US")} merged`);
480 if (pushes > 0) parts.push(`${pushes.toLocaleString("en-US")} pushed direct`);
481 if (deploys > 0) parts.push(`${deploys.toLocaleString("en-US")} ${deploys === 1 ? "deploy" : "deploys"}`);
482 if (published > 0) parts.push(`${published.toLocaleString("en-US")} published`);
483 return parts.join(" · ");
484}
485
486/**
487 * Everything that landed in the span, newest first: pull requests merged,
488 * commits pushed straight to a default branch, production builds that went
489 * live, releases and package versions published.
490 */
491export function LandedCard({ landed, missing, code, now, tz }: { landed: LandedChange[] | null; missing: string[]; code: boolean; now: number; tz: string | null }) {
492 if (!code) return null;
493 return (
494 <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}>
495 {!landed ? (
496 <Quiet>Code didn&apos;t answer, so what landed can&apos;t be listed right now.</Quiet>
497 ) : landed.length === 0 ? (
498 <Quiet>{missing.length > 0 ? "Nothing landed in what could be read." : "Nothing merged, pushed to a default branch, deployed to production or published in this span."}</Quiet>
499 ) : (
500 <Folded
501 items={landed}
502 render={(change) => (
503 <li key={change.key} className={rowClass}>
504 {LANDED_ICON[change.kind]}
505 <div className="min-w-0 grow">
506 <Link to={change.to} className="block truncate text-sm text-fg hover:underline">
507 {change.title}
508 </Link>
509 <p className="truncate text-xs text-muted">{change.detail}</p>
510 </div>
511 <When at={change.at} now={now} tz={tz} />
512 </li>
513 )}
514 />
515 )}
516 {landed && <Missing names={missing} what="everything that landed" />}
517 </Card>
518 );
519}
520
521export function DeploysCard({ deploys, complete, code, now, tz }: { deploys: DeployRow[] | null; complete: boolean; code: boolean; now: number; tz: string | null }) {
522 // A workspace that doesn't deploy has nothing to say here.
523 if (!code || (deploys && deploys.length === 0)) return null;
524 const failed = deploys?.filter((row) => row.outcome === "failed").length ?? 0;
525 return (
526 <Card title="Deploys" hint={deploys ? `${deploys.length.toLocaleString("en-US")} finished${failed ? ` · ${failed} failed` : ""}` : undefined}>
527 {!deploys ? (
528 <Quiet>Deployments didn&apos;t answer, so builds can&apos;t be listed right now.</Quiet>
529 ) : (
530 <Folded
531 items={deploys}
532 render={(row) => (
533 <li key={row.key} className={rowClass}>
534 <Rocket size={15} className={cn("shrink-0", row.outcome === "failed" ? "text-danger" : "text-success")} />
535 <div className="min-w-0 grow">
536 <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
537 {row.kind === "production" ? "Production" : `Preview of ${row.branch ?? "a branch"}`} of {row.project}
538 <span className={cn("ml-2 text-xs font-medium", row.outcome === "failed" ? "text-danger" : "text-success")}>
539 {row.outcome === "failed" ? "Failed" : "Went live"}
540 </span>
541 </Link>
542 <p className="truncate text-xs text-muted">
543 <span className="font-mono">{row.commit}</span> · by {row.by}
544 </p>
545 </div>
546 <When at={row.at} now={now} tz={tz} />
547 </li>
548 )}
549 />
550 )}
551 {deploys && !complete && <p className="border-t border-line px-5 py-2.5 text-xs text-faint">Some projects&apos; builds couldn&apos;t be read.</p>}
552 </Card>
553 );
554}
555
556// --- Spend in the span ------------------------------------------------------------
557
558/**
559 * Each kind of charge keeps its colour, whatever its rank: sandboxes and
560 * runners one, hosting another, storage and the rest grey.
561 */
562function kindColour(kind: string): string {
563 if (/sandbox|runner/i.test(kind)) return "bg-info";
564 if (/deploy|domain/i.test(kind)) return "bg-fg-soft";
565 if (/storage|git|cache|package/i.test(kind)) return "bg-muted";
566 return "bg-faint";
567}
568
569const utcDay = (day: string) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(Date.parse(`${day}T12:00:00Z`));
570
571export function SpendCard({ spend, slug, span }: { spend: Spend | null; slug: string; span: Pick<Span, "key" | "note" | "words"> }) {
572 return (
573 <Card title="Spent" link={{ label: "Billing", to: `/${slug}/-/billing` }}>
574 {!spend ? (
575 <Quiet>Billing didn&apos;t answer, so spend can&apos;t be shown right now.</Quiet>
576 ) : (
577 <div className="px-5 pb-5">
578 <div className="text-3xl font-semibold tracking-tight text-fg tabular-nums">{money(spend.totalMicros)}</div>
579 <p className="mt-1 text-xs text-muted">
580 {spend.from === spend.to ? utcDay(spend.from) : `${utcDay(spend.from)} – ${utcDay(spend.to)}`}
581 {spend.monthMicros != null && ` · ${money(spend.monthMicros)} this month so far`}
582 </p>
583 {spend.lines.length === 0 ? (
584 <p className="mt-4 text-sm text-muted">Nothing on the statement {inSpan(span)}.</p>
585 ) : (
586 <>
587 <div className="mt-4 flex h-2.5 gap-0.5 overflow-hidden rounded-full" role="img" aria-label="Spend by kind of charge">
588 {spend.lines.map((line) => (
589 <span key={line.kind} className={`h-full min-w-0.75 ${kindColour(line.kind)}`} style={{ flexGrow: line.micros }} />
590 ))}
591 </div>
592 <ul className="mt-3 space-y-2">
593 {spend.lines.map((line) => (
594 <li key={line.kind} className="flex items-center gap-2 text-sm">
595 <span className={`size-2 shrink-0 rounded-xs ${kindColour(line.kind)}`} aria-hidden />
596 <span className="min-w-0 grow truncate text-fg-soft">{line.kind}</span>
597 <span className="text-fg tabular-nums">{money(line.micros)}</span>
598 </li>
599 ))}
600 </ul>
601 </>
602 )}
603 <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint">
604 At price, as your statement shows it. The statement keeps whole days, midnight to midnight UTC, so this counts from the start of the day the span began.
605 </p>
606 </div>
607 )}
608 </Card>
609 );
610}
611
612export function SpendSkeleton() {
613 return (
614 <div className="space-y-3 px-5 pb-5" aria-busy="true">
615 <Skeleton className="h-8 w-28" />
616 <Skeleton className="h-2.5 w-full rounded-full" />
617 <Skeleton className="h-3 w-3/4" />
618 <Skeleton className="h-3 w-2/3" />
619 </div>
620 );
621}
622
623// --- Running now -------------------------------------------------------------------
624
625const RUNNING_ICON: Record<RunningRow["kind"], ReactNode> = {
626 workflow: <Workflow size={15} className="text-info" />,
627 deploy: <Rocket size={15} className="text-info" />,
628};
629
630export function RunningList({ rows, missing, now }: { rows: RunningRow[]; missing: string[]; now: number }) {
631 return (
632 <>
633 {rows.length === 0 ? (
634 <Quiet>{missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Workflow runs and builds show here while they go."}</Quiet>
635 ) : (
636 <Folded
637 items={rows}
638 render={(row) => (
639 <li key={row.key} className={rowClass}>
640 <span className="shrink-0">{RUNNING_ICON[row.kind]}</span>
641 <div className="min-w-0 grow">
642 <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
643 {row.title}
644 </Link>
645 <p className="truncate text-xs text-muted">{row.detail}</p>
646 </div>
647 <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-raised px-2 py-0.5 text-xs text-fg-soft">
648 <Loader size={11} className="animate-spin text-accent motion-reduce:animate-none" aria-hidden />
649 {row.status}
650 </span>
651 <span className="hidden w-14 shrink-0 text-right text-xs text-faint tabular-nums sm:inline" suppressHydrationWarning>
652 {waited(row.at, now)}
653 </span>
654 </li>
655 )}
656 />
657 )}
658 <Missing names={missing} what="everything running" />
659 </>
660 );
661}