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.

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