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.

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