Skip to content
485 linesCodeBlameRaw
1/**
2 * Today's parts (routes/workspace/home.tsx): the day's agent work, Needs
3 * attention, Spent today and Start here. Each takes what lib/today.ts
4 * worked out and only draws it.
5 */
6import {
7 AlertTriangle,
8 ArrowRight,
9 AtSign,
10 Bell,
11 Bot,
12 CircleDollarSign,
13 GitMerge,
14 GitPullRequest,
15 Hourglass,
16 Mail,
17 Rocket,
18 Server,
19 Sparkles,
20 User,
21} from "lucide-react";
22import type { ReactNode } from "react";
23import { Link } from "react-router";
24
25import {
26 type Attention,
27 type AttentionKind,
28 type AttentionRow,
29 type DayWork,
30 type Spend,
31 type Stake,
32 type TaskOutcome,
33 OUTCOME_LABEL,
34 SOURCE_LABEL,
35 dollars,
36 trendLabel,
37 waited,
38} from "../lib/today";
39import { Skeleton } from "./ui/skeleton";
40import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
41
42export function Card({ title, link, children, className = "" }: { title: ReactNode; link?: { label: string; to: string } | null; children: ReactNode; className?: string }) {
43 return (
44 <section className={`rounded-2xl border border-line bg-surface ${className}`}>
45 <header className="flex items-center gap-2 px-5 pt-4 pb-3">
46 <h2 className="text-sm font-semibold text-fg">{title}</h2>
47 {link && (
48 <Link to={link.to} className="ml-auto inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
49 {link.label}
50 <ArrowRight size={12} />
51 </Link>
52 )}
53 </header>
54 {children}
55 </section>
56 );
57}
58
59export function Quiet({ children }: { children: ReactNode }) {
60 return <p className="px-5 pb-5 text-sm text-muted">{children}</p>;
61}
62
63// --- The day's work -----------------------------------------------------------
64
65/** Each outcome's mark: status colours, always beside a label in the legend. */
66const MARK: Record<TaskOutcome, string> = {
67 accepted: "bg-success",
68 fixed: "bg-warn",
69 finished: "bg-info",
70 open: "bg-accent/45",
71 dropped: "bg-danger",
72};
73
74const percent = (value: number) => `${Math.round(value * 100)}%`;
75
76function Stat({ label, value, mark }: { label: string; value: number; mark?: string }) {
77 return (
78 <div className="min-w-0">
79 <div className="flex items-center gap-1.5 text-xs text-muted">
80 {mark && <span className={`size-2 shrink-0 rounded-[2px] ${mark}`} aria-hidden />}
81 <span className="truncate">{label}</span>
82 </div>
83 <div className="mt-1 text-xl font-semibold text-fg tabular-nums">{value.toLocaleString("en-US")}</div>
84 </div>
85 );
86}
87
88export function DayCard({ work }: { work: DayWork | null }) {
89 if (!work) {
90 return (
91 <section className="rounded-2xl border border-line bg-surface p-5">
92 <p className="text-sm text-muted">Today&apos;s agent work couldn&apos;t be read right now. It shows here once Agents answers.</p>
93 </section>
94 );
95 }
96 const { counts, rate, lastWeek, tasks } = work;
97 const trend = trendLabel(rate, lastWeek);
98 const up = rate && lastWeek ? Math.round(rate.value * 100) - Math.round(lastWeek.value * 100) : 0;
99 // Without Code, nothing is reviewed: no accepted or fixed to count.
100 const reviewed = work.code === "read";
101 const shown = (Object.keys(MARK) as TaskOutcome[]).filter((o) => counts[o] > 0 || (reviewed && (o === "accepted" || o === "fixed")) || o === "open");
102 return (
103 <section className="grid overflow-hidden rounded-2xl border border-line bg-surface md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)]">
104 <div className="border-b border-line p-5 md:border-r md:border-b-0">
105 {work.code === "no_access" ? (
106 <>
107 <h2 className="text-sm font-semibold text-fg">Finished today</h2>
108 <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{(counts.accepted + counts.fixed + counts.finished).toLocaleString("en-US")}</div>
109 <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>
110 </>
111 ) : (
112 <>
113 <h2 className="text-sm font-semibold text-fg">Accepted first time</h2>
114 {rate ? (
115 <>
116 <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{percent(rate.value)}</div>
117 <p className="mt-2 text-xs text-muted">
118 of {rate.of.toLocaleString("en-US")} agent {rate.of === 1 ? "pull request" : "pull requests"} merged or closed today
119 </p>
120 <p className={`mt-1.5 text-xs font-medium ${up > 0 ? "text-success" : up < 0 ? "text-warn" : "text-muted"}`}>
121 {trend ?? (work.code === "read" ? "Nothing settled in the last 7 days to compare." : "")}
122 </p>
123 </>
124 ) : (
125 <>
126 <div className="mt-2 text-5xl font-semibold tracking-tight text-faint">—</div>
127 <p className="mt-2 text-xs leading-relaxed text-muted">
128 {work.code === "unavailable" ? "Code didn't answer, so pull requests aren't counted." : "No agent pull request has merged or closed today yet."}
129 {lastWeek ? ` The last 7 days: ${percent(lastWeek.value)} of ${lastWeek.of}.` : ""}
130 </p>
131 </>
132 )}
133 </>
134 )}
135 </div>
136 <div className="min-w-0 p-5">
137 <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
138 <Stat label="Tasks today" value={tasks.length} />
139 {reviewed ? (
140 <>
141 <Stat label="Accepted first time" value={counts.accepted} mark={MARK.accepted} />
142 <Stat label="Fixed after review" value={counts.fixed} mark={MARK.fixed} />
143 </>
144 ) : (
145 <>
146 <Stat label="Sessions finished" value={counts.finished} mark={MARK.finished} />
147 <Stat label="Didn't finish" value={counts.dropped} mark={MARK.dropped} />
148 </>
149 )}
150 <Stat label="Still open" value={counts.open} mark={MARK.open} />
151 </div>
152 {tasks.length === 0 ? (
153 <p className="mt-5 text-sm text-muted">No agent work today yet.</p>
154 ) : (
155 <div className="mt-5">
156 <ul className="flex flex-wrap gap-[3px]" aria-label="Today's tasks">
157 {tasks.map((task) => (
158 <li key={task.key}>
159 <Tooltip>
160 <TooltipTrigger asChild>
161 {task.to ? (
162 <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`} />
163 ) : (
164 <span className={`block h-6 w-2.5 rounded-[3px] ${MARK[task.outcome]}`} />
165 )}
166 </TooltipTrigger>
167 <TooltipContent>
168 <span className="block font-medium">{task.title ?? "A session in a conversation you're not in"}</span>
169 <span className="text-muted">
170 {OUTCOME_LABEL[task.outcome]} · {SOURCE_LABEL[task.source]}
171 </span>
172 </TooltipContent>
173 </Tooltip>
174 </li>
175 ))}
176 </ul>
177 <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted">
178 <span className="text-faint">Every mark is one of today&apos;s tasks</span>
179 {shown.map((outcome) => (
180 <span key={outcome} className="inline-flex items-center gap-1.5">
181 <span className={`size-2 rounded-[2px] ${MARK[outcome]}`} aria-hidden />
182 {OUTCOME_LABEL[outcome]} <span className="text-fg-soft tabular-nums">{counts[outcome]}</span>
183 </span>
184 ))}
185 </div>
186 </div>
187 )}
188 {(work.partial || work.sessions === "unavailable" || work.code === "unavailable") && (
189 <p className="mt-3 text-xs text-faint">
190 {work.sessions === "unavailable"
191 ? "Agent sessions couldn't be read, so only pull requests are counted. "
192 : work.code === "unavailable"
193 ? "Code didn't answer, so only agent sessions are counted. "
194 : ""}
195 {work.partial ? "A list was cut short, so some of today's tasks may be missing." : ""}
196 </p>
197 )}
198 </div>
199 </section>
200 );
201}
202
203export function DaySkeleton() {
204 return (
205 <section className="grid rounded-2xl border border-line bg-surface md:grid-cols-[15rem_1fr]" aria-busy="true">
206 <div className="space-y-3 border-b border-line p-5 md:border-r md:border-b-0">
207 <Skeleton className="h-3.5 w-32" />
208 <Skeleton className="h-12 w-24" />
209 <Skeleton className="h-3 w-40" />
210 </div>
211 <div className="space-y-5 p-5">
212 <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
213 {[0, 1, 2, 3].map((i) => (
214 <div key={i} className="space-y-2">
215 <Skeleton className="h-3 w-20" />
216 <Skeleton className="h-5 w-8" />
217 </div>
218 ))}
219 </div>
220 <Skeleton className="h-6 w-3/4" />
221 </div>
222 </section>
223 );
224}
225
226// --- Needs attention -----------------------------------------------------------
227
228const ICON: Record<AttentionKind, ReactNode> = {
229 limit: <CircleDollarSign size={16} className="text-danger" />,
230 deploy: <Rocket size={16} className="text-danger" />,
231 session_cap: <CircleDollarSign size={16} className="text-warn" />,
232 agent_budget: <CircleDollarSign size={16} className="text-danger" />,
233 pull_blocked: <GitPullRequest size={16} className="text-warn" />,
234 ready: <GitMerge size={16} className="text-accent" />,
235 review: <GitPullRequest size={16} className="text-info" />,
236 invitation: <Mail size={16} className="text-info" />,
237 agent_waiting: <Sparkles size={16} className="text-warn" />,
238 stuck: <Hourglass size={16} className="text-warn" />,
239 runner: <Server size={16} className="text-warn" />,
240 notification: <Bell size={16} className="text-warn" />,
241 chat: <AtSign size={16} className="text-accent" />,
242};
243
244const STAKE_TONE: Record<NonNullable<Stake["tone"]> | "none", string> = {
245 danger: "text-danger",
246 warn: "text-warn",
247 accent: "text-accent",
248 none: "text-fg-soft",
249};
250
251function StakeText({ stake }: { stake: Stake }) {
252 return <span className={`text-xs font-medium tabular-nums ${STAKE_TONE[stake.tone ?? "none"]}`}>{stake.text}</span>;
253}
254
255/** Most rows Needs attention shows. */
256const ROWS = 6;
257
258function AttentionItem({ row }: { row: AttentionRow }) {
259 return (
260 <li className="flex items-start gap-3 border-t border-line px-5 py-3 first:border-t-0">
261 <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span>
262 <div className="min-w-0 grow">
263 <Link to={row.action.to} className="block truncate text-sm font-semibold text-fg hover:underline">
264 {row.title}
265 </Link>
266 <p className="truncate text-xs text-muted">{row.detail}</p>
267 {row.stake && (
268 <div className="mt-1 sm:hidden">
269 <StakeText stake={row.stake} />
270 </div>
271 )}
272 </div>
273 <div className="hidden max-w-48 shrink-0 pt-0.5 text-right whitespace-nowrap sm:block">{row.stake && <StakeText stake={row.stake} />}</div>
274 <Link
275 to={row.action.to}
276 className="inline-flex h-7 shrink-0 items-center rounded-md border border-line-strong px-2.5 text-xs font-medium text-fg transition-colors hover:border-accent/50 hover:bg-raised"
277 >
278 {row.action.label}
279 </Link>
280 </li>
281 );
282}
283
284export function AttentionList({ attention }: { attention: Attention }) {
285 const { rows, missing } = attention;
286 return (
287 <>
288 {rows.length === 0 ? (
289 <Quiet>{missing.length > 0 ? "Nothing is waiting on you in what could be read." : "Nothing is waiting on you."}</Quiet>
290 ) : (
291 <ul>
292 {rows.slice(0, ROWS).map((row) => (
293 <AttentionItem key={row.key} row={row} />
294 ))}
295 </ul>
296 )}
297 {rows.length > ROWS && (
298 <p className="border-t border-line px-5 py-2.5 text-xs text-muted">
299 {(rows.length - ROWS).toLocaleString("en-US")} more in{" "}
300 <Link to="/notifications" className="text-fg-soft underline-offset-2 hover:underline">
301 Notifications
302 </Link>
303 , Chat and Code.
304 </p>
305 )}
306 {missing.length > 0 && (
307 <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint">
308 <AlertTriangle size={12} className="shrink-0" />
309 {listOf(missing)} didn&apos;t answer, so what waits there isn&apos;t listed.
310 </p>
311 )}
312 </>
313 );
314}
315
316const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
317
318export function Sources({ work }: { work: DayWork | null }) {
319 return (
320 <div className="border-t border-line px-5 py-4">
321 <h3 className="text-xs font-semibold text-fg-soft">Where today&apos;s work came from</h3>
322 {!work ? (
323 <p className="mt-2 text-xs text-muted">Couldn&apos;t be read right now.</p>
324 ) : work.sources.length === 0 ? (
325 <p className="mt-2 text-xs text-muted">No agent work today yet.</p>
326 ) : (
327 <ul className="mt-2.5 flex flex-wrap gap-2">
328 {work.sources.map(({ source, count }) => (
329 <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">
330 <span className="text-muted">{SOURCE_LABEL[source]}</span>
331 <span className="font-semibold text-fg tabular-nums">{count.toLocaleString("en-US")}</span>
332 </li>
333 ))}
334 </ul>
335 )}
336 </div>
337 );
338}
339
340export function RowsSkeleton({ rows = 3 }: { rows?: number }) {
341 return (
342 <div className="space-y-4 px-5 pb-5" aria-busy="true">
343 {Array.from({ length: rows }, (_, i) => (
344 <div key={i} className="flex items-center gap-3">
345 <Skeleton className="size-4 rounded" />
346 <div className="grow space-y-1.5">
347 <Skeleton className="h-3.5" style={{ width: `${60 - i * 8}%` }} />
348 <Skeleton className="h-3" style={{ width: `${80 - i * 10}%` }} />
349 </div>
350 <Skeleton className="h-7 w-16" />
351 </div>
352 ))}
353 </div>
354 );
355}
356
357// --- Spent today --------------------------------------------------------------
358
359/**
360 * Each kind of charge keeps its colour, whatever its rank: models one,
361 * sandboxes and runners another, hosting a third, storage and the rest grey.
362 */
363function kindColour(kind: string): string {
364 if (/agent|model|gateway/i.test(kind)) return "bg-accent";
365 if (/sandbox|runner/i.test(kind)) return "bg-info";
366 if (/deploy|domain/i.test(kind)) return "bg-fg-soft";
367 if (/storage|git|cache|package/i.test(kind)) return "bg-muted";
368 return "bg-faint";
369}
370
371export function SpendCard({ spend, slug }: { spend: Spend | null; slug: string }) {
372 return (
373 <Card title="Spent today" link={{ label: "Billing", to: `/${slug}/-/billing` }}>
374 {!spend ? (
375 <Quiet>Billing didn&apos;t answer, so today&apos;s spend can&apos;t be shown right now.</Quiet>
376 ) : (
377 <div className="px-5 pb-5">
378 <div className="text-3xl font-semibold tracking-tight text-fg tabular-nums">{dollars(spend.totalMicros)}</div>
379 <p className="mt-1 text-xs text-muted">{dollars(spend.monthMicros)} this month so far</p>
380 {spend.lines.length === 0 ? (
381 <p className="mt-4 text-sm text-muted">Nothing on today&apos;s statement yet.</p>
382 ) : (
383 <>
384 <div className="mt-4 flex h-2.5 gap-[2px] overflow-hidden rounded-full" role="img" aria-label="Today's spend by kind of charge">
385 {spend.lines.map((line) => (
386 <span key={line.kind} className={`h-full min-w-[3px] ${kindColour(line.kind)}`} style={{ flexGrow: line.micros }} />
387 ))}
388 </div>
389 <ul className="mt-3 space-y-2">
390 {spend.lines.map((line) => (
391 <li key={line.kind} className="flex items-center gap-2 text-sm">
392 <span className={`size-2 shrink-0 rounded-[2px] ${kindColour(line.kind)}`} aria-hidden />
393 <span className="min-w-0 grow truncate text-fg-soft">{line.kind}</span>
394 <span className="text-fg tabular-nums">{dollars(line.micros)}</span>
395 </li>
396 ))}
397 </ul>
398 </>
399 )}
400 <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint">At price, as your statement shows it. Statement days run midnight to midnight UTC.</p>
401 </div>
402 )}
403 </Card>
404 );
405}
406
407export function SpendSkeleton() {
408 return (
409 <div className="space-y-3 px-5 pb-5" aria-busy="true">
410 <Skeleton className="h-8 w-28" />
411 <Skeleton className="h-2.5 w-full rounded-full" />
412 <Skeleton className="h-3 w-3/4" />
413 <Skeleton className="h-3 w-2/3" />
414 </div>
415 );
416}
417
418// --- Start here ---------------------------------------------------------------
419
420export function StartHere({ attention, now }: { attention: Attention; now: number }) {
421 const start = attention.start;
422 return (
423 <Card
424 title={
425 <>
426 Start here <span className="font-normal text-faint">· picked by g1t</span>
427 </>
428 }
429 >
430 {!start ? (
431 <Quiet>{attention.missing.length > 0 ? "Nothing to pick from what could be read." : "Nothing is waiting on you, so there's nothing to pick."}</Quiet>
432 ) : (
433 <div className="px-5 pb-5">
434 <div className="flex items-start gap-3">
435 <span className="mt-0.5 shrink-0">{ICON[start.row.kind]}</span>
436 <div className="min-w-0">
437 <p className="text-[0.9375rem] leading-snug font-semibold text-fg">{start.row.title}</p>
438 <p className="mt-1 line-clamp-2 text-xs text-muted">{start.row.detail}</p>
439 </div>
440 </div>
441 <dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
442 {start.row.owner && (
443 <>
444 <dt className="text-muted">Owner</dt>
445 <dd className="flex min-w-0 items-center gap-1.5 text-fg-soft">
446 {start.row.owner.agent ? <Bot size={12} className="shrink-0 text-accent" /> : <User size={12} className="shrink-0 text-muted" />}
447 <span className="truncate">{start.row.owner.agent ? `@${start.row.owner.name}` : start.row.owner.name}</span>
448 </dd>
449 </>
450 )}
451 <dt className="text-muted">Next</dt>
452 <dd className="text-fg-soft">{start.row.action.label}</dd>
453 {start.row.at > 0 && (
454 <>
455 <dt className="text-muted">Waiting</dt>
456 <dd className="text-fg-soft" suppressHydrationWarning>
457 {waited(start.row.at, now)}
458 </dd>
459 </>
460 )}
461 {start.row.stake && (
462 <>
463 <dt className="text-muted">At stake</dt>
464 <dd>
465 <StakeText stake={start.row.stake} />
466 </dd>
467 </>
468 )}
469 </dl>
470 <p className="mt-4 rounded-lg bg-raised/60 px-3 py-2.5 text-xs leading-relaxed text-fg-soft">
471 <span className="font-medium text-fg">Why this one: </span>
472 {start.why}
473 </p>
474 <Link
475 to={start.row.action.to}
476 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"
477 >
478 {start.row.action.label}
479 <ArrowRight size={14} />
480 </Link>
481 </div>
482 )}
483 </Card>
484 );
485}