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