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.

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