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