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.

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