Skip to content
485 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.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}