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