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.

855 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; a row that arrived while the
6 * page was open comes in (`animate-arrive`, lib/use-home-live.ts).
7 */
8import {
9 AlertTriangle,
10 ArrowRight,
11 AtSign,
12 Bell,
13 CheckCheck,
14 GitCommitHorizontal,
15 GitMerge,
16 GitPullRequest,
17 Loader,
18 Mail,
19 Package,
20 Rocket,
21 Server,
22 Sparkles,
23 Tag,
24 User,
25 Users,
26 Workflow,
27} from "lucide-react";
28import type { ReactNode } from "react";
29import { Link } from "react-router";
30
31import {
32 type Attention,
33 type AttentionKind,
34 type AttentionRow,
35 type Away,
36 type Digest,
37 type DigestActor,
38 type DigestColumn,
39 type WorkKind,
40 WORK_KINDS,
41 WORK_LABEL,
42 type LandedChange,
43 type RunningRow,
44 type ShipInfo,
45 type Span,
46 type Spend,
47 type Stake,
48 WINDOWS,
49 agentLabel,
50 explainQuestion,
51 landedByDay,
52 listOf,
53 plural,
54 shippedLabel,
55 timeOfDay,
56 waited,
57 whenShort,
58} from "../lib/home";
59import { askG1t } from "../lib/ask";
60import { useArrived } from "../lib/use-home-live";
61import { cn } from "../lib/cn";
62import { money } from "../lib/money";
63import { CommitText } from "./commit-message";
64import { Button } from "./ui/button";
65import { Hint } from "./ui/hint";
66import { ButtonLink } from "./ui";
67import { AgentMarker, Avatar } from "./ui/avatar";
68import { Badge, type BadgeTone } from "./ui/badge";
69import { Skeleton } from "./ui/skeleton";
70import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
71
72export function Card({
73 title,
74 hint,
75 link,
76 children,
77 className = "",
78}: {
79 title: ReactNode;
80 /** A few words under the title. */
81 hint?: string;
82 link?: { label: string; to: string } | null;
83 children: ReactNode;
84 className?: string;
85}) {
86 return (
87 <section className={`rounded-2xl border border-line bg-surface ${className}`}>
88 <header className="flex items-start gap-2 px-5 pt-4 pb-3">
89 <div className="min-w-0">
90 <h2 className="text-sm font-semibold text-fg">{title}</h2>
91 {hint && <p className="mt-0.5 text-xs text-muted">{hint}</p>}
92 </div>
93 {link && (
94 <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">
95 {link.label}
96 <ArrowRight size={12} />
97 </Link>
98 )}
99 </header>
100 {children}
101 </section>
102 );
103}
104
105export function Quiet({ children }: { children: ReactNode }) {
106 return <p className="px-5 pb-5 text-sm text-muted">{children}</p>;
107}
108
109function Missing({ names, what }: { names: string[]; what: string }) {
110 if (names.length === 0) return null;
111 return (
112 <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint">
113 <AlertTriangle size={12} className="shrink-0" />
114 {listOf(names)} didn&apos;t answer, so {what} isn&apos;t listed.
115 </p>
116 );
117}
118
119// --- The window switch ----------------------------------------------------------
120
121/** Since last visit, last 24 hours, last 7 days: links, so the span is in the address and works without script. */
122export function WindowSwitch({ span }: { span: Pick<Span, "key"> }) {
123 return (
124 <nav aria-label="What happened over" className="flex shrink-0 self-start rounded-lg border border-line bg-surface p-0.5">
125 {WINDOWS.map((option) => {
126 const current = option.key === span.key;
127 return (
128 <Link
129 key={option.key}
130 to={option.key === "last" ? "?" : `?window=${option.key}`}
131 preventScrollReset
132 aria-current={current ? "true" : undefined}
133 className={cn(
134 "rounded-md px-2.5 py-1 text-xs font-medium whitespace-nowrap transition-colors",
135 current ? "bg-raised text-fg shadow-[0_0_0_1px_var(--color-line-strong)]" : "text-muted hover:text-fg",
136 )}
137 >
138 {option.label}
139 </Link>
140 );
141 })}
142 </nav>
143 );
144}
145
146// --- Where you're needed ---------------------------------------------------------
147
148const ICON: Record<AttentionKind, ReactNode> = {
149 deploy: <Rocket size={16} className="text-danger" />,
150 pull_blocked: <GitPullRequest size={16} className="text-warn" />,
151 review: <GitPullRequest size={16} className="text-info" />,
152 invitation: <Mail size={16} className="text-info" />,
153 ready: <GitMerge size={16} className="text-success" />,
154 runner: <Server size={16} className="text-warn" />,
155 notification: <Bell size={16} className="text-warn" />,
156 chat: <AtSign size={16} className="text-accent" />,
157};
158
159const STAKE_TONE: Record<NonNullable<Stake["tone"]> | "none", string> = {
160 danger: "text-danger",
161 warn: "text-warn",
162 accent: "text-accent",
163 none: "text-fg-soft",
164};
165
166function StakeText({ stake }: { stake: Stake }) {
167 return <span className={`text-xs font-medium tabular-nums ${STAKE_TONE[stake.tone ?? "none"]}`}>{stake.text}</span>;
168}
169
170/** Rows shown before the rest fold away. */
171const ROWS = 8;
172
173/** A row that arrived while the page was open: in from above, lit for a moment. */
174const ARRIVED = "animate-arrive motion-reduce:animate-none";
175
176function NeedItem({ row, now, arrived }: { row: AttentionRow; now: number; arrived?: boolean }) {
177 return (
178 <li className={cn("flex items-start gap-3 border-t border-line px-5 py-3 first:border-t-0", arrived && ARRIVED)}>
179 <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span>
180 <div className="min-w-0 grow">
181 <Link to={row.action.to} className="line-clamp-2 text-sm font-semibold text-fg hover:underline sm:block sm:truncate">
182 {row.title}
183 </Link>
184 <p className="truncate text-xs text-muted">{row.detail}</p>
185 <div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs sm:hidden">
186 {row.stake && <StakeText stake={row.stake} />}
187 {row.at > 0 && (
188 <span className="text-faint" suppressHydrationWarning>
189 waiting {waited(row.at, now)}
190 </span>
191 )}
192 </div>
193 </div>
194 <div className="hidden max-w-48 shrink-0 pt-0.5 text-right whitespace-nowrap sm:block">
195 {row.stake && <StakeText stake={row.stake} />}
196 {row.at > 0 && (
197 <div className="text-[0.6875rem] text-faint tabular-nums" suppressHydrationWarning>
198 waiting {waited(row.at, now)}
199 </div>
200 )}
201 </div>
202 <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">
203 {/* It opens the pull request, where it merges: "Merge" here would read as merging on the spot. */}
204 {row.kind === "ready" ? "Open" : row.action.label}
205 </ButtonLink>
206 </li>
207 );
208}
209
210/**
211 * Everything waiting on you, most pressing and longest waiting first, with
212 * the one g1t picked to start with on top and why; past the first few, the
213 * rest fold away.
214 */
215export function NeedsList({ attention, now }: { attention: Attention; now: number }) {
216 const { start, missing } = attention;
217 const rows = start ? attention.rows.filter((row) => row.key !== start.row.key) : attention.rows;
218 const arrived = useArrived(attention.rows.map((row) => row.key));
219 return (
220 <>
221 {start ? (
222 <StartRow start={start} now={now} arrived={arrived.has(start.row.key)} />
223 ) : (
224 attention.rows.length === 0 && (
225 <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>
226 )
227 )}
228 {rows.length > 0 && (
229 <ul className={cn(start && "border-t border-line")}>
230 {rows.slice(0, ROWS).map((row) => (
231 <NeedItem key={row.key} row={row} now={now} arrived={arrived.has(row.key)} />
232 ))}
233 </ul>
234 )}
235 {rows.length > ROWS && (
236 <details className="group border-t border-line">
237 <summary className="cursor-pointer list-none px-5 py-2.5 text-xs font-medium text-fg-soft hover:text-fg">
238 <span className="group-open:hidden">Show {(rows.length - ROWS).toLocaleString("en-US")} more</span>
239 <span className="hidden group-open:inline">Show fewer</span>
240 </summary>
241 <ul className="border-t border-line">
242 {rows.slice(ROWS).map((row) => (
243 <NeedItem key={row.key} row={row} now={now} arrived={arrived.has(row.key)} />
244 ))}
245 </ul>
246 </details>
247 )}
248 <Missing names={missing} what="what waits there" />
249 </>
250 );
251}
252
253export function RowsSkeleton({ rows = 3 }: { rows?: number }) {
254 return (
255 <div className="space-y-4 px-5 pb-5" aria-busy="true">
256 {Array.from({ length: rows }, (_, i) => (
257 <div key={i} className="flex items-center gap-3">
258 <Skeleton className="size-4 rounded" />
259 <div className="grow space-y-1.5">
260 <Skeleton className="h-3.5" style={{ width: `${60 - i * 8}%` }} />
261 <Skeleton className="h-3" style={{ width: `${80 - i * 10}%` }} />
262 </div>
263 <Skeleton className="h-7 w-16" />
264 </div>
265 ))}
266 </div>
267 );
268}
269
270// --- Start here -----------------------------------------------------------------
271
272/** Rows about a pull request: Start here's button opens it, and says so, rather than naming the step (Merge, Review) it can't take there. */
273const PULL_KINDS = new Set<AttentionKind>(["ready", "review", "pull_blocked"]);
274
275/** The one g1t picked to start with: the first of Where you're needed, a step bigger, with why. */
276function StartRow({ start, now, arrived }: { start: NonNullable<Attention["start"]>; now: number; arrived?: boolean }) {
277 const { row } = start;
278 const owner = row.owner ? (row.owner.agent ? `@${row.owner.name}` : row.owner.name) : null;
279 return (
280 <div className={cn("mx-3 mb-3 rounded-xl border border-accent/25 bg-accent/5 px-4 py-3.5", arrived && ARRIVED)}>
281 <p className="text-[0.6875rem] font-medium tracking-wide text-accent uppercase">Start here · picked by g1t</p>
282 <div className="mt-2 flex items-start gap-3">
283 <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span>
284 <div className="min-w-0 grow">
285 <Link to={row.action.to} className="text-[0.9375rem] leading-snug font-semibold text-fg hover:underline">
286 {row.title}
287 </Link>
288 <p className="mt-0.5 line-clamp-2 text-xs text-muted">{row.detail}</p>
289 <p className="mt-2 text-xs leading-relaxed text-fg-soft">{start.why}</p>
290 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
291 <ButtonLink to={row.action.to} variant="accent" size="sm">
292 {PULL_KINDS.has(row.kind) ? "Open pull request" : row.action.label}
293 <ArrowRight size={14} />
294 </ButtonLink>
295 <span className="flex flex-wrap items-center gap-x-2 text-xs text-faint">
296 {row.stake && <StakeText stake={row.stake} />}
297 {row.at > 0 && <span suppressHydrationWarning>waiting {waited(row.at, now)}</span>}
298 {owner && (
299 <span className="inline-flex items-center gap-1">
300 <User size={11} />
301 {owner}
302 </span>
303 )}
304 </span>
305 </div>
306 </div>
307 </div>
308 </div>
309 );
310}
311
312// --- What people did in the span ------------------------------------------------
313
314/** "in the span" said the way the span is: "since you were last here", "in the last 24 hours". */
315function inSpan(span: Pick<Span, "key" | "note" | "words">): string {
316 return span.key === "last" && span.note == null ? "since you were last here" : span.words.charAt(0).toLowerCase() + span.words.slice(1);
317}
318
319/** Each kind's colour: the chart's categorical slots, in WORK_KINDS order (app.css --color-series-*). */
320const WORK_FILL: Record<WorkKind, string> = {
321 pushes: "bg-series-1",
322 pulls: "bg-series-2",
323 issues: "bg-series-3",
324 shipped: "bg-series-4",
325 docs: "bg-series-5",
326 chat: "bg-series-6",
327};
328
329/** The most rows the chart shows; the rest are counted under it. */
330const WHO_ROWS = 6;
331
332/** What one actor's bar says, for a screen reader and the tooltip's lead. */
333function actorWords(actor: DigestActor): string {
334 const parts = WORK_KINDS.filter((kind) => actor.parts[kind]).map((kind) => `${WORK_LABEL[kind].toLowerCase()} ${actor.parts[kind]!.toLocaleString("en-US")}`);
335 return `${actor.name}${actor.kind === "agent" ? " (agent)" : ""}: ${parts.join(", ")}`;
336}
337
338/**
339 * Who did what: a row per person or agent, most active first, each a bar
340 * split by kind of work and as long as their share of the busiest. A
341 * segment names itself on hover; the legend under it is the totals.
342 */
343function WhoChart({ column }: { column: DigestColumn }) {
344 const shown = column.actors.slice(0, WHO_ROWS);
345 const more = column.actors.length - shown.length;
346 const top = Math.max(1, ...shown.map((actor) => actor.count));
347 return (
348 <ul className="mt-4 space-y-2.5" aria-label="Who did what">
349 {shown.map((actor) => (
350 <li key={actor.key} className="grid grid-cols-[minmax(0,8.5rem)_minmax(0,1fr)_2.5rem] items-center gap-3" aria-label={actorWords(actor)}>
351 <span className="flex min-w-0 items-center gap-2">
352 <Avatar name={actor.name} size={20} agent={actor.kind === "agent"} ring="var(--color-surface)" />
353 <span className="truncate text-sm text-fg">{actor.name}</span>
354 </span>
355 <span className="flex h-2.5 gap-0.5 overflow-hidden rounded-full" style={{ width: `${Math.max(4, (actor.count / top) * 100)}%` }} aria-hidden>
356 {WORK_KINDS.filter((kind) => actor.parts[kind]).map((kind) => (
357 <Tooltip key={kind}>
358 <TooltipTrigger asChild>
359 <span className={cn("h-full min-w-1 transition-opacity hover:opacity-80", WORK_FILL[kind])} style={{ flexGrow: actor.parts[kind] }} />
360 </TooltipTrigger>
361 <TooltipContent>
362 {actor.name} · {WORK_LABEL[kind]}: {actor.parts[kind]!.toLocaleString("en-US")}
363 {kind === "pushes" && actor.commits > 0 ? ` (${plural(actor.commits, "commit")})` : ""}
364 </TooltipContent>
365 </Tooltip>
366 ))}
367 </span>
368 <span className="text-right text-xs text-muted tabular-nums">{actor.count.toLocaleString("en-US")}</span>
369 </li>
370 ))}
371 {more > 0 && <li className="text-xs text-muted">and {plural(more, "more")}</li>}
372 </ul>
373 );
374}
375
376/** The legend: each kind with its colour, what it was in words, linked to where it is listed. */
377function WorkLegend({ column }: { column: DigestColumn }) {
378 return (
379 <ul className="mt-4 grid gap-x-6 gap-y-1.5 border-t border-line pt-3 sm:grid-cols-2">
380 {column.lines.map((line) => {
381 const body = (
382 <>
383 <span className={cn("mt-1.25 size-2.5 shrink-0 rounded-[3px]", WORK_FILL[line.key])} aria-hidden />
384 <span className="min-w-0">
385 <span className="font-medium text-fg">{WORK_LABEL[line.key]}</span> <span className="text-muted">{line.text}</span>
386 </span>
387 </>
388 );
389 return (
390 <li key={line.key} className="text-sm">
391 {line.to ? (
392 <Link to={line.to} className="group flex items-start gap-2 hover:[&_.text-muted]:text-fg-soft">
393 {body}
394 </Link>
395 ) : (
396 <span className="flex items-start gap-2">{body}</span>
397 )}
398 </li>
399 );
400 })}
401 </ul>
402 );
403}
404
405/** "2 people · 1 agent". */
406function whoCount(actors: DigestActor[]): string {
407 const people = actors.filter((actor) => actor.kind === "person").length;
408 const agents = actors.length - people;
409 return [people > 0 ? plural(people, "person", "people") : null, agents > 0 ? plural(agents, "agent") : null].filter(Boolean).join(" · ");
410}
411
412/**
413 * What people did in the span, the top of the span's card: lines with
414 * counts linked to where each thing is listed, and the most active faces.
415 * It says when it is empty, and which part didn't answer.
416 */
417export function PeopleSummary({ digest, span, code }: { digest: Digest | null; span: Pick<Span, "key" | "note" | "words">; code: boolean }) {
418 const when = inSpan(span);
419 if (!digest) {
420 return (
421 <div className="px-5 py-4">
422 <p className="text-sm text-muted">What happened couldn&apos;t be read right now. It shows here once Code and Chat answer.</p>
423 </div>
424 );
425 }
426 const silent = digest.missing.filter((name) => name === "Code" || name === "Chat");
427 const col = digest.people;
428 return (
429 <div className="px-5 py-4" aria-label="What people did">
430 <div className="flex flex-wrap items-center gap-3">
431 <h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-fg">
432 <Users size={15} className="text-muted" />
433 People
434 </h3>
435 {col.actors.length > 0 && (
436 <Badge tone="neutral" size="md" className="tabular-nums">
437 {whoCount(col.actors)}
438 </Badge>
439 )}
440 </div>
441 {col.lines.length > 0 ? (
442 <>
443 {col.actors.length > 0 && <WhoChart column={col} />}
444 <WorkLegend column={col} />
445 </>
446 ) : (
447 <p className="mt-3 text-sm text-muted">
448 {silent.length > 0
449 ? `Nothing from what could be read ${when}.`
450 : code
451 ? `Nothing pushed, opened, merged, reviewed, edited or said ${when}.`
452 : `No doc edited and nothing said ${when}.`}
453 </p>
454 )}
455 {silent.length > 0 && (
456 <p className="mt-3 flex items-center gap-1.5 text-xs text-faint">
457 <AlertTriangle size={12} className="shrink-0" />
458 {listOf(silent)} didn&apos;t answer, so {silent.length === 1 ? "its part" : "their parts"} {silent.length === 1 ? "isn't" : "aren't"} counted.
459 </p>
460 )}
461 {digest.partial && <p className="mt-3 text-xs text-faint">More happened than Home reads at once, so some counts are low.</p>}
462 </div>
463 );
464}
465
466export function SpanSkeleton() {
467 return (
468 <div className="space-y-3 px-5 py-4" aria-busy="true">
469 <div className="flex items-center justify-between">
470 <Skeleton className="h-3.5 w-20" />
471 <Skeleton className="h-5 w-24 rounded-full" />
472 </div>
473 <Skeleton className="h-3.5 w-3/4" />
474 <Skeleton className="h-3.5 w-1/2" />
475 <Skeleton className="h-3.5 w-2/3" />
476 </div>
477 );
478}
479
480// --- Landed and builds -----------------------------------------------------------
481
482/** Rows each list shows before the rest fold away. */
483const LIST_ROWS = 5;
484
485function When({ at, now, tz, timeOnly }: { at: number; now: number; tz: string | null; timeOnly?: boolean }) {
486 return (
487 <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning>
488 {timeOnly ? timeOfDay(at, tz) : whenShort(at, now, tz)}
489 </span>
490 );
491}
492
493function Folded<T>({ items, render }: { items: T[]; render: (item: T) => ReactNode }) {
494 return (
495 <>
496 <ul>{items.slice(0, LIST_ROWS).map(render)}</ul>
497 {items.length > LIST_ROWS && (
498 <details className="group border-t border-line">
499 <summary className="cursor-pointer list-none px-5 py-2.5 text-xs font-medium text-fg-soft hover:text-fg">
500 <span className="group-open:hidden">Show {(items.length - LIST_ROWS).toLocaleString("en-US")} more</span>
501 <span className="hidden group-open:inline">Show fewer</span>
502 </summary>
503 <ul className="border-t border-line">{items.slice(LIST_ROWS).map(render)}</ul>
504 </details>
505 )}
506 </>
507 );
508}
509
510const rowClass = "flex items-center gap-3 border-t border-line px-5 py-2.5 first:border-t-0";
511
512const LANDED_ICON: Record<LandedChange["kind"], ReactNode> = {
513 merge: <GitMerge size={15} className="shrink-0 text-success" />,
514 push: <GitCommitHorizontal size={15} className="shrink-0 text-success" />,
515 deploy: <Rocket size={15} className="shrink-0 text-success" />,
516 release: <Tag size={15} className="shrink-0 text-info" />,
517 package: <Package size={15} className="shrink-0 text-info" />,
518};
519
520/** A few words on the card's line: how many of each kind landed. */
521function landedHint(landed: LandedChange[]): string {
522 const count = (kind: LandedChange["kind"]) => landed.filter((change) => change.kind === kind).length;
523 const parts: string[] = [];
524 const merged = count("merge");
525 const pushes = count("push");
526 const deploys = count("deploy");
527 const published = count("release") + count("package");
528 if (merged > 0) parts.push(`${merged.toLocaleString("en-US")} merged`);
529 if (pushes > 0) parts.push(`${pushes.toLocaleString("en-US")} pushed direct`);
530 if (deploys > 0) parts.push(`${deploys.toLocaleString("en-US")} ${deploys === 1 ? "deploy" : "deploys"}`);
531 if (published > 0) parts.push(`${published.toLocaleString("en-US")} published`);
532 return parts.join(" · ");
533}
534
535const SHIP_TONE: Record<ShipInfo["state"], BadgeTone> = {
536 live: "success",
537 deploying: "info",
538 waiting: "neutral",
539 failed: "danger",
540};
541
542/** Whether a merge or a push is live yet, linked to the deployment that shipped it (or the one under way, or the one that failed). */
543function Shipped({ change }: { change: LandedChange }) {
544 const ship = change.shipped;
545 if (!ship) return null;
546 const label = shippedLabel(ship, change);
547 const badge = (
548 <Badge tone={SHIP_TONE[ship.state]} className="relative z-10">
549 {ship.state === "live" && <Rocket size={10} aria-hidden="true" />}
550 {ship.state === "deploying" && <Loader size={10} aria-hidden="true" className="animate-spin motion-reduce:animate-none" />}
551 {label}
552 </Badge>
553 );
554 return ship.to ? (
555 <Link to={ship.to} className="relative z-10 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
556 {badge}
557 </Link>
558 ) : (
559 badge
560 );
561}
562
563/** The agent marker on a change made in a harness session, linked to the session. */
564function Agent({ change }: { change: LandedChange }) {
565 const mark = change.agent;
566 if (!mark) return null;
567 const label = agentLabel(mark);
568 const marker = <AgentMarker size={16} inline />;
569 return (
570 <Tooltip>
571 <TooltipTrigger asChild>
572 {mark.to ? (
573 <Link to={mark.to} aria-label={label} className="relative z-10 inline-flex shrink-0 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
574 {marker}
575 </Link>
576 ) : (
577 <span role="img" aria-label={label} className="inline-flex shrink-0">
578 {marker}
579 </span>
580 )}
581 </TooltipTrigger>
582 <TooltipContent>{label}</TooltipContent>
583 </Tooltip>
584 );
585}
586
587/** Explain one change that landed: what it did and why, in plain English, in the top bar's Ask (components/home-ask). */
588function ExplainButton({ change }: { change: LandedChange }) {
589 return (
590 <Hint label="Explain this change in plain English">
591 <Button type="button" variant="ghost" size="icon-xs" aria-label={`Explain ${change.title}`} onClick={() => askG1t(explainQuestion(change))} className="shrink-0 text-muted hover:text-accent">
592 <Sparkles size={13} />
593 </Button>
594 </Hint>
595 );
596}
597
598function LandedItem({ change, now, tz, timeOnly, arrived }: { change: LandedChange; now: number; tz: string | null; timeOnly?: boolean; arrived?: boolean }) {
599 return (
600 <li className={cn(rowClass, "items-start", arrived && ARRIVED)}>
601 <span className="mt-0.5 shrink-0">{change.failed ? <Rocket size={15} className="shrink-0 text-danger" /> : LANDED_ICON[change.kind]}</span>
602 <div className="min-w-0 grow">
603 <div className="flex min-w-0 items-center gap-1.5">
604 {change.refsIn ? (
605 // A commit's subject: its `#12`s link to their issues, the rest to the commit.
606 <span className="line-clamp-2 text-sm text-fg sm:truncate">
607 <CommitText text={change.title} base={`/${change.refsIn}`} to={change.to} linkClassName="hover:underline" />
608 </span>
609 ) : (
610 <Link to={change.to} className="line-clamp-2 text-sm text-fg hover:underline sm:truncate">
611 {change.title}
612 </Link>
613 )}
614 <Agent change={change} />
615 </div>
616 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
617 <p className="min-w-0 truncate text-xs text-muted">{change.detail}</p>
618 <Shipped change={change} />
619 </div>
620 </div>
621 <ExplainButton change={change} />
622 <When at={change.at} now={now} tz={tz} timeOnly={timeOnly} />
623 </li>
624 );
625}
626
627/** How many of the span's merges and pushes are live, deploying, waiting or failed: the feed's line. */
628function shipFacts(landed: LandedChange[]): { key: string; text: string; tone: BadgeTone }[] {
629 const count = (state: ShipInfo["state"]) => landed.filter((change) => change.shipped?.state === state).length;
630 const failed = count("failed") + landed.filter((change) => change.failed).length;
631 const facts: { key: string; text: string; tone: BadgeTone }[] = [];
632 if (count("live") > 0) facts.push({ key: "live", text: `${count("live").toLocaleString("en-US")} live`, tone: "success" });
633 if (count("deploying") > 0) facts.push({ key: "deploying", text: `${count("deploying").toLocaleString("en-US")} deploying`, tone: "info" });
634 if (count("waiting") > 0) facts.push({ key: "waiting", text: `${count("waiting").toLocaleString("en-US")} waiting to deploy`, tone: "neutral" });
635 if (failed > 0) facts.push({ key: "failed", text: `${failed.toLocaleString("en-US")} deploy failed`, tone: "danger" });
636 return facts;
637}
638
639/**
640 * Everything that landed in the span, newest first, in the span's card:
641 * pull requests merged, commits pushed straight to a default branch,
642 * releases and package versions published, and production builds no merge
643 * or push already speaks for (`landedDeploys`). Each merge and push says
644 * whether it is live yet, and one made in a harness session carries the
645 * agent marker. Over the last 7 days, day by day.
646 */
647export function LandedFeed({
648 landed,
649 missing,
650 code,
651 now,
652 tz,
653 byDay = false,
654 scope,
655}: {
656 landed: LandedChange[] | null;
657 missing: string[];
658 code: boolean;
659 now: number;
660 tz: string | null;
661 byDay?: boolean;
662 /** The span: switching it lists other things, which are not arrivals. */
663 scope?: string;
664}) {
665 const arrived = useArrived(landed?.map((change) => change.key) ?? [], scope);
666 if (!code) return null;
667 const render = (change: LandedChange) => <LandedItem key={change.key} change={change} now={now} tz={tz} timeOnly={byDay} arrived={arrived.has(change.key)} />;
668 const facts = landed ? shipFacts(landed) : [];
669 return (
670 <div className="border-t border-line">
671 <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 px-5 pt-3.5 pb-2">
672 <h3 className="text-sm font-semibold text-fg">Landed</h3>
673 {landed && landed.length > 0 && <span className="text-xs text-muted">{landedHint(landed)}</span>}
674 {facts.length > 0 && (
675 <ul className="flex flex-wrap gap-1.5 sm:ml-auto" aria-label="Deployed">
676 {facts.map((fact) => (
677 <li key={fact.key}>
678 <Badge tone={fact.tone}>{fact.text}</Badge>
679 </li>
680 ))}
681 </ul>
682 )}
683 </div>
684 {!landed ? (
685 <Quiet>Code didn&apos;t answer, so what landed can&apos;t be listed right now.</Quiet>
686 ) : landed.length === 0 ? (
687 <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>
688 ) : byDay ? (
689 landedByDay(landed, now, tz).map((day) => (
690 <section key={day.key} aria-label={day.label} className="border-t border-line">
691 <h4 className="flex items-baseline gap-2 bg-raised/40 px-5 py-1.5 text-xs font-medium text-fg-soft">
692 {day.label}
693 <span className="font-normal text-faint tabular-nums">{day.rows.length.toLocaleString("en-US")}</span>
694 </h4>
695 <Folded items={day.rows} render={render} />
696 </section>
697 ))
698 ) : (
699 <div className="border-t border-line">
700 <Folded items={landed} render={render} />
701 </div>
702 )}
703 {landed && <Missing names={missing} what="everything that landed" />}
704 </div>
705 );
706}
707
708// --- Settled for you ---------------------------------------------------------------
709
710/**
711 * Your notifications that settled by themselves in the span (a later run
712 * passed, it merged, it deployed), each with why: in the side column.
713 * What shipped is the Landed feed's; what still needs you, Where you're
714 * needed.
715 */
716export function SettledCard({ away, now }: { away: Away | null; now: number }) {
717 const count = away?.settled.length ?? 0;
718 return (
719 <Card title="Settled for you" hint={away ? (count > 0 ? `${count.toLocaleString("en-US")} finished by themselves` : "Nothing finished by itself") : undefined} link={{ label: "Done", to: "/notifications/done" }}>
720 {!away ? (
721 <Quiet>Your notifications couldn&apos;t be read, so what settled can&apos;t be listed right now.</Quiet>
722 ) : count === 0 ? null : (
723 <div className="border-t border-line">
724 <Folded
725 items={away.settled}
726 render={(row) => (
727 <li key={row.key} className={cn(rowClass, "items-start px-4")}>
728 <CheckCheck size={14} className="mt-0.5 shrink-0 text-success" aria-hidden="true" />
729 <div className="min-w-0 grow">
730 <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
731 {row.title}
732 </Link>
733 <p className="truncate text-xs text-muted">
734 <span className="text-fg-soft">{row.why}</span>
735 {row.where && ` · ${row.where}`}
736 </p>
737 </div>
738 <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning>
739 {waited(row.at, now)}
740 </span>
741 </li>
742 )}
743 />
744 </div>
745 )}
746 </Card>
747 );
748}
749
750// --- Spend in the span ------------------------------------------------------------
751
752/**
753 * Each kind of charge keeps its colour, whatever its rank: sandboxes and
754 * runners one, hosting another, storage and the rest grey.
755 */
756function kindColour(kind: string): string {
757 if (/sandbox|runner/i.test(kind)) return "bg-info";
758 if (/deploy|domain/i.test(kind)) return "bg-fg-soft";
759 if (/storage|git|cache|package/i.test(kind)) return "bg-muted";
760 return "bg-faint";
761}
762
763const utcDay = (day: string) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(Date.parse(`${day}T12:00:00Z`));
764
765export function SpendCard({ spend, slug, span }: { spend: Spend | null; slug: string; span: Pick<Span, "key" | "note" | "words"> }) {
766 return (
767 <Card title="Spent" link={{ label: "Billing", to: `/${slug}/-/billing` }}>
768 {!spend ? (
769 <Quiet>Billing didn&apos;t answer, so spend can&apos;t be shown right now.</Quiet>
770 ) : (
771 <div className="px-5 pb-5">
772 <div className="text-2xl font-semibold tracking-tight text-fg tabular-nums">{money(spend.totalMicros)}</div>
773 <p className="mt-1 text-xs text-muted">
774 {spend.from === spend.to ? utcDay(spend.from) : `${utcDay(spend.from)} – ${utcDay(spend.to)}`}
775 {spend.monthMicros != null && ` · ${money(spend.monthMicros)} this month so far`}
776 </p>
777 {spend.lines.length === 0 ? (
778 <p className="mt-4 text-sm text-muted">Nothing on the statement {inSpan(span)}.</p>
779 ) : (
780 <>
781 <div className="mt-4 flex h-2.5 gap-0.5 overflow-hidden rounded-full" role="img" aria-label="Spend by kind of charge">
782 {spend.lines.map((line) => (
783 <span key={line.kind} className={`h-full min-w-0.75 ${kindColour(line.kind)}`} style={{ flexGrow: line.micros }} />
784 ))}
785 </div>
786 <ul className="mt-3 space-y-2">
787 {spend.lines.map((line) => (
788 <li key={line.kind} className="flex items-center gap-2 text-sm">
789 <span className={`size-2 shrink-0 rounded-xs ${kindColour(line.kind)}`} aria-hidden />
790 <span className="min-w-0 grow truncate text-fg-soft">{line.kind}</span>
791 <span className="text-fg tabular-nums">{money(line.micros)}</span>
792 </li>
793 ))}
794 </ul>
795 </>
796 )}
797 <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint">At price, by whole UTC days from the day the span began.</p>
798 </div>
799 )}
800 </Card>
801 );
802}
803
804export function SpendSkeleton() {
805 return (
806 <div className="space-y-3 px-5 pb-5" aria-busy="true">
807 <Skeleton className="h-8 w-28" />
808 <Skeleton className="h-2.5 w-full rounded-full" />
809 <Skeleton className="h-3 w-3/4" />
810 <Skeleton className="h-3 w-2/3" />
811 </div>
812 );
813}
814
815// --- Running now -------------------------------------------------------------------
816
817const RUNNING_ICON: Record<RunningRow["kind"], ReactNode> = {
818 workflow: <Workflow size={15} className="text-info" />,
819 deploy: <Rocket size={15} className="text-info" />,
820};
821
822export function RunningList({ rows, missing, now }: { rows: RunningRow[]; missing: string[]; now: number }) {
823 const arrived = useArrived(rows.map((row) => row.key));
824 return (
825 <>
826 {rows.length === 0 ? (
827 <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>
828 ) : (
829 <Folded
830 items={rows}
831 render={(row) => (
832 // In the side column: the title gets the width, its state and how long under it.
833 <li key={row.key} className={cn(rowClass, "items-start px-4", arrived.has(row.key) && ARRIVED)}>
834 <span className="mt-0.5 shrink-0">{RUNNING_ICON[row.kind]}</span>
835 <div className="min-w-0 grow">
836 <Link to={row.to} className="line-clamp-2 text-sm text-fg hover:underline">
837 {row.title}
838 </Link>
839 <p className="truncate text-xs text-muted">{row.detail}</p>
840 <p className="mt-1 flex items-center gap-1.5 text-xs text-fg-soft">
841 <Loader size={11} className="animate-spin text-accent motion-reduce:animate-none" aria-hidden />
842 {row.status}
843 <span className="text-faint tabular-nums" suppressHydrationWarning>
844 · {waited(row.at, now)}
845 </span>
846 </p>
847 </div>
848 </li>
849 )}
850 />
851 )}
852 <Missing names={missing} what="everything running" />
853 </>
854 );
855}