Skip to content

g1t/apps/web/app/components/mission-control.tsx

1,203 lines50,258 bytesCodeBlame
1import { ArrowDownWideNarrow, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, ListTree, Plus, Sparkles } from "lucide-react";
2import { type ReactNode, useEffect, useState } from "react";
3import { Link, useFetcher, useRouteLoaderData, useSearchParams } from "react-router";
4
5import { trialClosed } from "../lib/trial";
6import { type ActivityGroup, type Verb, greetingFor, isAgent } from "../lib/mission";
7import {
8 BLOCKING,
9 type Fact,
10 type LandedRow,
11 type NeedRow,
12 type QuickAction,
13 REASON_LABEL,
14 type Reason,
15 type Sort,
16 type Tab,
17 type WaitingRow,
18 type Week,
19 type Who,
20 change,
21 dateLine,
22 parseSort,
23 parseTab,
24 signedPercent,
25 sortRows,
26 usd,
27 whyFor,
28} from "../lib/mission-control";
29import { cn } from "../lib/cn";
30import { AgentComposer, type ComposerResult } from "./agent-composer";
31import { AskComposer } from "./ask-composer";
32import { AgentSetup } from "./agent-setup";
33import { InboxNeedsCard } from "./inbox";
34import type { ShellData } from "./shell";
35import { useLiveRefresh } from "./agents";
36import { Unavailable } from "./mission";
37import { TokenUsagePanel } from "./token-usage";
38import { Avatar, SubmitButton, TimeAgo } from "./ui";
39import { Hint } from "./ui/hint";
40import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
41import type { Loaded } from "../routes/home";
42
43/*
44 * Mission control: the home page of someone signed in. It answers where
45 * the viewer is needed, what agents are doing, and what landed without
46 * them. It is a chunk of its own, loaded only for them, so the signed-out
47 * home page does not carry it. routes/home.tsx loads its data;
48 * lib/mission-control.ts shapes it.
49 */
50
51/** The viewer's time zone, set by the page itself, so the greeting fits their day. */
52const TZ_COOKIE = "g1t_tz";
53/** Rows a tab shows before "See all". */
54const ROWS = 6;
55
56const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`;
57const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
58
59// --- Small pieces -----------------------------------------------------------------
60
61const CHIP_TONE: Record<Reason, string> = {
62 blocking: "border-danger/35 bg-danger/10 text-danger",
63 checks_failing: "border-danger/35 bg-danger/10 text-danger",
64 outside_guardrails: "border-warn/35 bg-warn/10 text-warn",
65 low_confidence: "border-warn/35 bg-warn/10 text-warn",
66 stalled: "border-warn/35 bg-warn/10 text-warn",
67 asked_for_you: "border-accent/35 bg-accent/10 text-accent",
68 needs_review: "border-info/35 bg-info/10 text-info",
69 ready_to_merge: "border-success/35 bg-success/10 text-success",
70};
71
72function Chip({ children, tone }: { children: ReactNode; tone: string }) {
73 return (
74 <span
75 className={cn(
76 "inline-flex shrink-0 items-center rounded border px-1.5 py-px font-mono text-[0.625rem] font-medium tracking-wider whitespace-nowrap uppercase",
77 tone,
78 )}
79 >
80 {children}
81 </span>
82 );
83}
84
85/** A person or an agent, by name, wearing the mark that tells them apart. */
86function Person({ who, size = 16, className }: { who: Who; size?: number; className?: string }) {
87 return (
88 <span className={cn("inline-flex min-w-0 items-center gap-1.5", className)}>
89 <Avatar name={who.name} size={size} />
90 <span className="truncate">{who.name}</span>
91 </span>
92 );
93}
94
95function Eyebrow({ children }: { children: ReactNode }) {
96 return <p className="font-mono text-[0.625rem] font-medium tracking-[0.12em] text-faint uppercase">{children}</p>;
97}
98
99const FACT_TONE = { good: "text-success", warn: "text-warn", bad: "text-danger" } as const;
100
101function Facts({ facts }: { facts: Fact[] }) {
102 if (facts.length === 0) return <p className="text-sm text-muted">Nothing more is known about it yet.</p>;
103 return (
104 <dl className="grid grid-cols-2 gap-x-4 gap-y-2.5">
105 {facts.map((fact) => (
106 <div key={fact.label} className={cn("min-w-0", fact.wide && "col-span-2")}>
107 <dt className="text-xs text-faint">{fact.label}</dt>
108 <dd
109 className={cn(
110 "mt-0.5 text-sm font-medium tabular-nums",
111 fact.wide ? "leading-5" : "truncate",
112 fact.tone ? FACT_TONE[fact.tone] : "text-fg-soft",
113 )}
114 >
115 {fact.value}
116 </dd>
117 </div>
118 ))}
119 </dl>
120 );
121}
122
123/**
124 * A form that acts on a pull request from here: working until the action
125 * has answered and the page has reloaded what it changed, then done, or
126 * the error beside it.
127 */
128function QuickForm({ quick, variant = "quiet" }: { quick: QuickAction; variant?: "quiet" | "accent" }) {
129 const fetcher = useFetcher<{ error?: string } | null>();
130 const [sent, setSent] = useState(false);
131 const done = sent && fetcher.state === "idle" && !fetcher.data?.error;
132 return (
133 <fetcher.Form method="post" action={quick.to} onSubmit={() => setSent(true)} className="contents">
134 {Object.entries(quick.fields).map(([name, value]) => (
135 <input key={name} type="hidden" name={name} value={value} />
136 ))}
137 <SubmitButton
138 fetcher={fetcher}
139 disabled={done}
140 className={cn(
141 "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-default",
142 variant === "accent"
143 ? "bg-accent text-bg hover:bg-accent-hover"
144 : "border border-line-strong text-fg/90 hover:bg-raised hover:text-fg",
145 done && "border-success/40 text-success",
146 )}
147 >
148 {done ? <Check size={14} /> : null}
149 {done ? quick.done : quick.label}
150 </SubmitButton>
151 {fetcher.data?.error && <span className="basis-full text-xs text-danger">{fetcher.data.error}</span>}
152 </fetcher.Form>
153 );
154}
155
156function ProjectMark({ repo }: { repo: { namespace: string; name: string } | null }) {
157 return <Avatar name={repo ? repo.name : "g1t"} size={32} square />;
158}
159
160/** A row that opens to what is known about it; the first one starts open. */
161function Row({
162 open,
163 repo,
164 by,
165 title,
166 sub,
167 chip,
168 at,
169 children,
170}: {
171 open: boolean;
172 repo: { namespace: string; name: string } | null;
173 by: Who | null;
174 title: ReactNode;
175 sub: ReactNode;
176 chip: ReactNode;
177 at: number;
178 children: ReactNode;
179}) {
180 return (
181 <li>
182 <details open={open} className="group">
183 <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-3 transition-colors select-none hover:bg-raised/60 sm:px-5 [&::-webkit-details-marker]:hidden">
184 <ProjectMark repo={repo} />
185 <span className="min-w-0 grow">
186 <span className="block truncate text-sm">{title}</span>
187 <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
188 <span className="shrink-0 font-medium text-fg-soft">{repo ? repo.name : "Workspace"}</span>
189 {by && (
190 <>
191 <span className="text-faint">·</span>
192 <Person who={by} size={14} className={cn("shrink-0", by.agent ? "text-accent" : "")} />
193 </>
194 )}
195 {sub && (
196 <>
197 <span className="text-faint">—</span>
198 <span className="truncate">{sub}</span>
199 </>
200 )}
201 </span>
202 </span>
203 <span className="hidden sm:inline-flex">{chip}</span>
204 <span className="hidden w-16 shrink-0 text-right text-xs text-faint sm:block">
205 <TimeAgo at={at} />
206 </span>
207 <ChevronDown size={16} className="shrink-0 text-faint transition-transform group-open:rotate-180" />
208 </summary>
209 <div className="px-4 pb-4 sm:px-5 sm:pb-5">
210 <div className="mb-3 flex items-center gap-2 sm:hidden">
211 {chip}
212 <span className="text-xs text-faint">
213 <TimeAgo at={at} />
214 </span>
215 </div>
216 {children}
217 </div>
218 </details>
219 </li>
220 );
221}
222
223function NeedCard({ row, first }: { row: NeedRow; first: boolean }) {
224 const isPull = row.to.includes("/pull/");
225 return (
226 <Row
227 open={first}
228 repo={row.repo}
229 by={row.by}
230 title={
231 <>
232 <span className="font-medium">{row.title}</span>
233 {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>}
234 </>
235 }
236 sub={row.ask}
237 chip={<Chip tone={CHIP_TONE[row.reason]}>{REASON_LABEL[row.reason]}</Chip>}
238 at={row.at}
239 >
240 <div className="grid gap-4 rounded-xl border border-line bg-bg/50 p-4 md:grid-cols-2 md:gap-x-6 2xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,0.95fr)]">
241 <div className="min-w-0">
242 <Eyebrow>The ask</Eyebrow>
243 <p className="mt-2 text-sm leading-6 text-fg-soft">{row.ask}</p>
244 <p className="mt-2 text-xs leading-5 text-muted">
245 <Link to={row.to} className="font-medium text-fg hover:underline">
246 {row.repo ? `${row.repo.name}${row.ref ?? ""}` : row.title}
247 </Link>
248 {row.for && (
249 <>
250 {" "}
251 · started for <span className="text-fg-soft">{row.for}</span>
252 </>
253 )}
254 </p>
255 </div>
256 <div className="min-w-0">
257 <Eyebrow>{row.by?.agent ? "What the agent already knows" : "What is known"}</Eyebrow>
258 <div className="mt-2">
259 <Facts facts={row.facts} />
260 </div>
261 </div>
262 <div className="rounded-lg border border-warn/20 bg-warn/[0.06] p-3.5 md:col-span-2 2xl:col-span-1">
263 <p className="text-xs font-semibold text-warn">Why this needs you</p>
264 <p className="mt-1.5 text-sm leading-6 text-fg-soft">{row.why}</p>
265 <p className="mt-2 text-xs text-faint">
266 Started waiting <TimeAgo at={row.at} />
267 </p>
268 </div>
269 </div>
270 <div className="mt-3 flex flex-wrap items-center gap-2">
271 <Link
272 to={row.to}
273 prefetch="intent"
274 className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-white"
275 >
276 Review and respond <ArrowUpRight size={14} />
277 </Link>
278 {row.quick && <QuickForm quick={row.quick} />}
279 {row.link && (
280 <Link
281 to={row.link.to}
282 className="inline-flex items-center rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
283 >
284 {row.link.label}
285 </Link>
286 )}
287 {row.open !== row.to && (
288 <Link to={row.open} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg">
289 {row.open.includes("/issues/") ? "Open issue" : isPull ? "See the changes" : "Open pull request"}
290 </Link>
291 )}
292 </div>
293 </Row>
294 );
295}
296
297function WaitingCard({ row, first }: { row: WaitingRow; first: boolean }) {
298 return (
299 <Row
300 open={first}
301 repo={row.repo}
302 by={row.by}
303 title={
304 <>
305 <span className="font-medium">{row.title}</span>
306 {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>}
307 </>
308 }
309 sub={row.detail}
310 chip={
311 <Chip tone="border-line-strong bg-raised text-muted">
312 {row.live && <span className="mr-1.5 size-1.5 animate-pulse rounded-full bg-success" />}
313 {row.chip}
314 </Chip>
315 }
316 at={row.at}
317 >
318 <div className="grid gap-4 rounded-xl border border-line bg-bg/50 p-4 md:grid-cols-2 md:gap-6">
319 <div className="min-w-0">
320 <Eyebrow>{row.live ? "Doing now" : "Where it stands"}</Eyebrow>
321 <p className="mt-2 text-sm leading-6 text-fg-soft">{row.detail}</p>
322 </div>
323 <div className="min-w-0">
324 <Eyebrow>What is known</Eyebrow>
325 <div className="mt-2">
326 <Facts facts={row.facts} />
327 </div>
328 </div>
329 </div>
330 <div className="mt-3 flex flex-wrap gap-2">
331 <Link
332 to={row.to}
333 prefetch="intent"
334 className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
335 >
336 {row.to.includes("/runs/") ? "Watch the run" : "Open pull request"} <ArrowUpRight size={14} />
337 </Link>
338 {row.run && row.run !== row.to && (
339 <Link to={row.run} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg">
340 Watch the run
341 </Link>
342 )}
343 </div>
344 </Row>
345 );
346}
347
348function LandedCard({ row, first }: { row: LandedRow; first: boolean }) {
349 return (
350 <Row
351 open={first}
352 repo={row.repo}
353 by={row.byAgents ? { name: row.agent, agent: isAgent(row.agent) } : row.by}
354 title={
355 <>
356 <span className="font-medium">{row.title}</span>
357 <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>
358 </>
359 }
360 sub={row.byAgents ? "landed without a person" : `merged by ${row.by?.name ?? "a person"}`}
361 chip={
362 row.byAgents ? (
363 <Chip tone="border-accent/35 bg-accent/10 text-accent">By agents</Chip>
364 ) : (
365 <Chip tone="border-warn/35 bg-warn/10 text-warn">Needed a person</Chip>
366 )
367 }
368 at={row.at}
369 >
370 <div className="grid gap-4 rounded-xl border border-line bg-bg/50 p-4 md:grid-cols-2 md:gap-6">
371 <div className="min-w-0">
372 <Eyebrow>How it landed</Eyebrow>
373 <p className="mt-2 text-sm leading-6 text-fg-soft">
374 {row.byAgents
375 ? `g1t merged it once everything the repository asks for was met, with no one pressing merge.`
376 : `${row.by?.name ?? "A person"} merged it.`}
377 {row.agent && isAgent(row.agent) && ` ${row.agent} made the change.`}
378 </p>
379 </div>
380 <div className="min-w-0">
381 <Eyebrow>What changed</Eyebrow>
382 <div className="mt-2">
383 <Facts facts={row.facts} />
384 </div>
385 </div>
386 </div>
387 <div className="mt-3">
388 <Link
389 to={row.to}
390 prefetch="intent"
391 className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
392 >
393 Open pull request <ArrowUpRight size={14} />
394 </Link>
395 </div>
396 </Row>
397 );
398}
399
400function List({ children }: { children: ReactNode }) {
401 return <ul className="divide-y divide-line">{children}</ul>;
402}
403
404function Empty({ children, action }: { children: ReactNode; action?: ReactNode }) {
405 return (
406 <div className="px-5 py-10 text-center">
407 <div className="mx-auto max-w-md text-sm leading-6 text-muted">{children}</div>
408 {action && <div className="mt-4 flex flex-wrap justify-center gap-2">{action}</div>}
409 </div>
410 );
411}
412
413// --- The stat strip -----------------------------------------------------------------
414
415function Stat({ label, value, hint, dot, title }: { label: string; value: string; hint: ReactNode; dot?: string; title?: string }) {
416 return (
417 <Hint label={title}>
418 <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4">
419 <p className="flex items-center gap-1.5 truncate text-xs text-muted">
420 {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />}
421 {label}
422 </p>
423 <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p>
424 <p className="mt-0.5 truncate text-xs text-faint">{hint}</p>
425 </div>
426 </Hint>
427 );
428}
429
430// --- This week ----------------------------------------------------------------------
431
432/** Seven days of landed changes, each split into what agents landed alone and what a person merged. */
433function WeekChart({ week }: { week: Week }) {
434 const max = Math.max(1, ...week.days.map((d) => d.agents + d.assisted + d.people));
435 const height = 112;
436 const delta = change(week.total, week.previous);
437 return (
438 <div>
439 <div className="flex items-end justify-between gap-3">
440 <div>
441 <p className="font-display text-3xl font-semibold tracking-tight tabular-nums">{week.total.toLocaleString("en-US")}</p>
442 <p className="text-xs text-muted">changes landed in 7 days</p>
443 </div>
444 {delta != null ? (
445 <span
446 className={cn(
447 "rounded-full px-2 py-0.5 text-xs font-medium tabular-nums ring-1",
448 delta >= 0 ? "text-success ring-success/30" : "text-warn ring-warn/30",
449 )}
450 >
451 {signedPercent(delta)} vs last week
452 </span>
453 ) : week.previous === 0 && week.total > 0 ? (
454 <span className="text-xs text-faint">none the week before</span>
455 ) : null}
456 </div>
457 {week.total === 0 ? (
458 <p className="mt-4 rounded-lg border border-dashed border-line px-4 py-6 text-center text-sm leading-6 text-muted">
459 Nothing landed in the last 7 days. Each change that does shows here, by day: agents' changes that landed on their own, agents' that a person merged, and people's own.
460 </p>
461 ) : (
462 <div className="relative mt-5" style={{ height: height + 20 }}>
463 <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} />
464 <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}>
465 {week.days.map((day, index) => {
466 const total = day.agents + day.assisted + day.people;
467 const bar = (n: number) => Math.max(3, Math.round((n / max) * (height - 4)));
468 // Top to bottom: agents alone, agents with a person, people.
469 const segments = [
470 { n: day.agents, tone: "bg-accent" },
471 { n: day.assisted, tone: "bg-warn" },
472 { n: day.people, tone: "bg-info" },
473 ].filter((segment) => segment.n > 0);
474 const today = index === week.days.length - 1;
475 return (
476 <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end">
477 {total > 0 && (
478 <span
479 className={cn(
480 "mb-1 text-[0.625rem] tabular-nums",
481 today ? "text-fg-soft" : "text-faint opacity-0 group-hover:opacity-100",
482 )}
483 >
484 {total}
485 </span>
486 )}
487 <div className="flex w-full max-w-6 flex-col items-stretch gap-[2px]">
488 {segments.map((segment, at) => (
489 <span key={segment.tone} className={cn("block", segment.tone, at === 0 && "rounded-t")} style={{ height: bar(segment.n) }} />
490 ))}
491 {total === 0 && <span className="block h-[2px] rounded-full bg-line-strong" />}
492 </div>
493 <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 group-hover:block">
494 <p className="font-medium text-fg">{day.label}</p>
495 <p className="mt-0.5 flex items-center gap-1.5 text-muted">
496 <span className="size-1.5 rounded-full bg-accent" /> {day.agents} by agents on their own
497 </p>
498 <p className="flex items-center gap-1.5 text-muted">
499 <span className="size-1.5 rounded-full bg-warn" /> {day.assisted} by agents, merged by a person
500 </p>
501 <p className="flex items-center gap-1.5 text-muted">
502 <span className="size-1.5 rounded-full bg-info" /> {day.people} by people
503 </p>
504 </div>
505 </div>
506 );
507 })}
508 </div>
509 <div className="absolute inset-x-0 bottom-0 flex justify-between gap-1">
510 {week.days.map((day, index) => (
511 <span
512 key={day.key}
513 className={cn(
514 "flex-1 text-center text-[0.6875rem]",
515 index === week.days.length - 1 ? "font-medium text-fg-soft" : "text-faint",
516 )}
517 >
518 {day.label}
519 </span>
520 ))}
521 </div>
522 </div>
523 )}
524 <div className={cn("mt-3 flex-wrap gap-x-4 gap-y-1 text-xs text-muted", week.total === 0 ? "hidden" : "flex")}>
525 <span className="inline-flex items-center gap-1.5">
526 <span className="size-2 rounded-sm bg-accent" /> Agents, on their own
527 </span>
528 <span className="inline-flex items-center gap-1.5">
529 <span className="size-2 rounded-sm bg-warn" /> Agents, merged by a person
530 </span>
531 <span className="inline-flex items-center gap-1.5">
532 <span className="size-2 rounded-sm bg-info" /> People
533 </span>
534 </div>
535 {/* A table does not shrink to sr-only's 1px; its wrapper does. */}
536 <div className="sr-only">
537 <table>
538 <caption>Changes landed each day</caption>
539 <thead>
540 <tr>
541 <th>Day</th>
542 <th>Agents, on their own</th>
543 <th>Agents, merged by a person</th>
544 <th>People</th>
545 </tr>
546 </thead>
547 <tbody>
548 {week.days.map((day) => (
549 <tr key={day.key}>
550 <td>{day.key}</td>
551 <td>{day.agents}</td>
552 <td>{day.assisted}</td>
553 <td>{day.people}</td>
554 </tr>
555 ))}
556 </tbody>
557 </table>
558 </div>
559 </div>
560 );
561}
562
563// --- Activity -----------------------------------------------------------------------
564
565const VERB: Record<Verb, string> = {
566 landed: "landed",
567 opened_issue: "opened",
568 closed_issue: "closed",
569 started: "started on",
570 ready: "marked ready",
571 checks_passed: "checks passed on",
572 checks_failed: "checks failed on",
573 approved: "approved",
574 changes_requested: "asked for changes on",
575 commented: "commented on",
576 asked: "was asked about",
577 deployed: "deployed production",
578 deploy_failed: "production build failed",
579 learned: "learned",
580};
581
582/** One line of the feed: who, what, and the one thing it was about, by name. */
583function FeedLine({ group, titles }: { group: ActivityGroup; titles: Record<string, string> }) {
584 const part = group.parts[0];
585 const base = `/${group.repo.namespace}/${group.repo.name}`;
586 const actor = group.actor ?? "g1t";
587 const agent = group.actor == null || isAgent(group.actor);
588 const number = part.numbers[0];
589 const title = number != null ? titles[`${group.repo.namespace}/${group.repo.name}#${number}`.toLowerCase()] : undefined;
590 const more = group.parts.length - 1 + Math.max(0, part.numbers.length - 1);
591 return (
592 <li className="flex gap-2.5 py-2">
593 <span className="mt-px">
594 <Avatar name={actor} size={22} />
595 </span>
596 <span className="min-w-0 grow text-[0.8125rem] leading-5 text-muted">
597 <span className={cn("font-medium", agent ? "text-accent" : "text-fg")}>{actor}</span> {VERB[part.verb]}{" "}
598 {part.verb === "learned" ? (
599 <Link to={part.to ?? `${base}/memory`} className="text-fg-soft hover:text-fg">
600 “{part.texts[0]}”
601 </Link>
602 ) : number != null ? (
603 <Link to={`${base}/issues/${number}`} prefetch="intent" className="text-fg-soft hover:text-fg">
604 <span className="font-mono text-xs">#{number}</span>
605 {title && <> {title}</>}
606 </Link>
607 ) : part.to ? (
608 <Link to={part.to} className="text-fg-soft hover:text-fg">
609 {group.repo.name}
610 </Link>
611 ) : null}
612 {more > 0 && <span className="text-faint"> and {more} more</span>}
613 <span className="block text-xs text-faint">
614 <span className="font-mono">{group.repo.name}</span> · <TimeAgo at={group.at} />
615 </span>
616 </span>
617 </li>
618 );
619}
620
621// --- Get started --------------------------------------------------------------------
622
623type Step = { done: boolean; title: string; about: string; to: string | null; action: string };
624
625/** The first things to do, ticked off as they are done, until work has been handed to agents. */
626function GetStarted({ steps }: { steps: Step[] }) {
627 const left = steps.filter((step) => !step.done).length;
628 return (
629 <section className="rounded-xl border border-line bg-surface p-5">
630 <div className="flex items-baseline justify-between gap-3">
631 <h2 className="text-base font-semibold tracking-tight">Get started</h2>
632 <span className="text-xs text-muted">
633 {steps.length - left} of {steps.length} done
634 </span>
635 </div>
636 <ol className="mt-4 space-y-2">
637 {steps.map((step, index) => (
638 <li
639 key={step.title}
640 className={cn("flex items-start gap-3 rounded-lg px-3 py-2.5", step.done ? "" : "bg-bg/50 ring-1 ring-line")}
641 >
642 <span
643 className={cn(
644 "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-medium",
645 step.done ? "bg-success text-bg" : "text-muted ring-1 ring-line-strong",
646 )}
647 >
648 {step.done ? <Check size={12} /> : index + 1}
649 </span>
650 <span className="min-w-0 grow">
651 <span className={cn("block text-sm font-medium", step.done && "text-muted line-through decoration-faint")}>{step.title}</span>
652 {!step.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{step.about}</span>}
653 </span>
654 {!step.done && step.to && (
655 <Link
656 to={step.to}
657 className="shrink-0 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg transition-colors hover:bg-white"
658 >
659 {step.action}
660 </Link>
661 )}
662 </li>
663 ))}
664 </ol>
665 </section>
666 );
667}
668
669// --- The page -----------------------------------------------------------------------
670
671const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting on agents", landed: "Landed today" };
672const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting", landed: "Today" };
673const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" };
674
675export default function MissionControl({ loaderData, delegated = null }: { loaderData: Loaded; delegated?: ComposerResult }) {
676 const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined;
677 const loaded: Loaded = loaderData;
678 const [params] = useSearchParams();
679 // Agents are at work, so the page changes without anyone touching it.
680 useLiveRefresh(loaded.changing);
681 const [greeting, setGreeting] = useState(loaded.greeting);
682 const [date, setDate] = useState(loaded.date);
683 useEffect(() => {
684 // The greeting and date follow the viewer's own clock, and the server
685 // learns their zone for next time.
686 setGreeting(greetingFor(new Date().getHours()));
687 try {
688 const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
689 setDate(dateLine(Date.now(), zone ?? null));
690 if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`;
691 } catch {
692 // Nothing to remember.
693 }
694 }, []);
695
696 const { viewer, repos, waiting, landed, liveTotal, week, groups, titles, stats, canRunAgents, trial } = loaded;
697 const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null;
698
699 // The usage limit, as the sidebar knows it, heads the list when it bites.
700 const limit = shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace ? shell.limit : null;
701 const limitRow: NeedRow[] = limit
702 ? [
703 (() => {
704 const detail =
705 limit.state === "stopped"
706 ? "No new agent starts until the limit is raised or a card is added."
707 : `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`;
708 return {
709 key: "limit",
710 reason: "blocking" as const,
711 repo: null,
712 ref: null,
713 title: limit.state === "stopped" ? "Agents are stopped: the usage limit was reached" : "Usage is nearing its limit",
714 ask: detail,
715 by: null,
716 for: null,
717 at: Date.now(),
718 to: `/${workspace}/-/billing`,
719 open: `/${workspace}/-/billing`,
720 facts: [
721 { label: "Used", value: `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)}`, tone: "warn" as const },
722 ],
723 why: whyFor("blocking", { kind: "limit", detail }),
724 quick: null,
725 link: null,
726 };
727 })(),
728 ]
729 : [];
730 const needs = [...limitRow, ...loaded.needs];
731 const blocking = needs.filter((row) => BLOCKING.has(row.reason)).length;
732
733 const counts: Record<Tab, number> = { needs: needs.length, waiting: waiting.length, landed: landed.length };
734 const tab = parseTab(params.get("tab")) ?? "needs";
735 const sort = parseSort(params.get("sort"));
736 const all = params.get("all") === "1";
737 const everyActivity = params.get("activity") === "all";
738 const link = (changes: Record<string, string | null>) => {
739 const next = new URLSearchParams(params);
740 for (const [key, value] of Object.entries(changes)) {
741 if (value == null) next.delete(key);
742 else next.set(key, value);
743 }
744 const text = next.toString();
745 return text ? `?${text}` : "/";
746 };
747
748 const steps: Step[] = [
749 {
750 done: Boolean(workspace),
751 title: "Create a workspace",
752 about: "Projects, people and agent credit live in one.",
753 to: "/workspaces/new",
754 action: "Create",
755 },
756 {
757 done: repos.length > 0,
758 title: "Add a project",
759 about: "Create a repository, or import one by its address. Push to it with git as usual.",
760 to: workspace ? `/new?workspace=${workspace}` : "/new",
761 action: "Add",
762 },
763 trial?.open
764 ? {
765 done: true,
766 title: "Try g1t free",
767 about: trial.granted
768 ? `This workspace has ${dollars(trial.limitMicros - trial.usedMicros)} of its ${dollars(trial.limitMicros)} trial credit left, for g1t's models and sandboxes.`
769 : `This workspace gets ${dollars(trial.limitMicros)} of trial credit, for g1t's models and sandboxes, the first time g1t works here.`,
770 to: workspace ? `/${workspace}/-/integrations` : null,
771 action: "Connect",
772 }
773 : canRunAgents
774 ? {
775 done: Boolean(shell?.free) || shell?.limit == null || shell.limit.comped || shell.limit.state === "ok",
776 title: "Keep work running",
777 about:
778 "Usage is charged after it runs. Add a card under Billing, so g1t charges it as you near your limit instead of stopping work.",
779 to: workspace ? `/${workspace}/-/billing` : null,
780 action: "Billing",
781 }
782 : {
783 done: false,
784 title: "Connect a model",
785 about: `${
786 trialClosed(trial, workspace ?? "This workspace") ? `${trialClosed(trial, workspace ?? "This workspace")} ` : ""
787 }Agents need a model to think with. Connect your Anthropic or OpenAI key, or any compatible endpoint.`,
788 to: workspace ? `/${workspace}/-/integrations` : null,
789 action: "Connect",
790 },
791 {
792 done: loaded.handedOff,
793 title: "Hand off an outcome",
794 about:
795 "Open an issue and assign it to g1t, or write an outcome on a project's Plans page and let a planner split it into issues.",
796 to: repos[0] ? `/${repos[0].namespace}/${repos[0].name}/plans` : null,
797 action: "Write one",
798 },
799 ];
800 const starting = steps.some((step) => !step.done);
801 const noProjects = repos.length === 0;
802
803 const rows = tab === "needs" ? sortRows(needs, sort) : tab === "waiting" ? sortRows(waiting, sort) : sortRows(landed, sort);
804 const shown = all ? rows : rows.slice(0, ROWS);
805 // Of the agents' own changes only: people's work is not theirs to land.
806 const share = week.agentChanges > 0 ? week.byAgents / week.agentChanges : null;
807 const delta = change(week.total, week.previous);
808 const feed = everyActivity ? groups : groups.slice(0, 8);
809
810 // "Put an agent on it" first, with "New issue" beside it as before: one
811 // split control, the agent the main way in.
812 const newIssue =
813 repos.length > 0 ? (
814 <div className="flex items-stretch">
815 <AgentComposer
816 repos={repos}
817 open={params.get("agent") === "new" || delegated != null}
818 result={delegated}
819 note={
820 canRunAgents ? null : (
821 <>
822 Agents need a model first.{" "}
823 {workspace && (
824 <Link to={`/${workspace}/-/integrations`} className="font-medium text-fg hover:underline">
825 Connect one
826 </Link>
827 )}
828 . The issue still opens.
829 </>
830 )
831 }
832 >
833 <span className="inline-flex cursor-pointer items-center gap-1.5 rounded-l-md border border-line-strong bg-raised px-3 py-2 text-sm font-medium text-fg transition-colors hover:bg-line/60 group-open/composer:bg-line/60">
834 <Sparkles size={14} className="text-accent" /> Put an agent on it
835 </span>
836 </AgentComposer>
837 <DropdownMenu>
838 <DropdownMenuTrigger className="-ml-px inline-flex items-center gap-1.5 rounded-r-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 transition-colors hover:bg-raised hover:text-fg">
839 <Plus size={14} /> New issue
840 </DropdownMenuTrigger>
841 <DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
842 <DropdownMenuLabel>In which project?</DropdownMenuLabel>
843 {repos.map((repo) => (
844 <DropdownMenuItem key={repo.name} asChild>
845 <Link to={`/${repo.namespace}/${repo.name}/issues/new`}>
846 <Avatar name={repo.name} size={18} square />
847 {repo.name}
848 </Link>
849 </DropdownMenuItem>
850 ))}
851 </DropdownMenuContent>
852 </DropdownMenu>
853 </div>
854 ) : (
855 <Link
856 to={workspace ? `/new?workspace=${workspace}` : "/new"}
857 className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
858 >
859 <Plus size={14} /> New project
860 </Link>
861 );
862
863 return (
864 <main className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
865 <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
866 <div className="min-w-0">
867 <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight sm:text-[2rem]" suppressHydrationWarning>
868 {greeting}, {loaded.name}
869 </h1>
870 <p className="mt-1.5 text-sm text-muted" suppressHydrationWarning>
871 <span className="text-fg-soft">{date}</span> · {loaded.summary}
872 </p>
873 </div>
874 </header>
875
876 {/* The agent is not on yet; the actions under it do the work today. */}
877 <AskComposer>
878 {newIssue}
879 {repos.length > 0 && (
880 <DropdownMenu>
881 <DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 transition-colors hover:bg-raised hover:text-fg">
882 <ListTree size={14} /> Plan work
883 </DropdownMenuTrigger>
884 <DropdownMenuContent align="start" className="max-h-80 overflow-y-auto">
885 <DropdownMenuLabel>Plan in which project?</DropdownMenuLabel>
886 {repos.map((repo) => (
887 <DropdownMenuItem key={repo.name} asChild>
888 <Link to={`/${repo.namespace}/${repo.name}/plans`}>
889 <Avatar name={repo.name} size={18} square />
890 {repo.name}
891 </Link>
892 </DropdownMenuItem>
893 ))}
894 </DropdownMenuContent>
895 </DropdownMenu>
896 )}
897 {needs.length > 0 && (
898 <Link
899 to={`${link({ tab: "needs", all: null })}#work`}
900 preventScrollReset
901 className="inline-flex items-center gap-1.5 rounded-md bg-accent px-3.5 py-2 text-sm font-semibold text-bg transition-colors hover:bg-accent-hover"
902 >
903 Review {needs.length} that need{needs.length === 1 ? "s" : ""} you <ArrowRight size={14} />
904 </Link>
905 )}
906 </AskComposer>
907
908 {starting && <GetStarted steps={steps} />}
909
910 <section
911 aria-label="At a glance"
912 className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-5"
913 >
914 <Stat
915 label="Projects"
916 value={stats.projects == null ? "—" : String(stats.projects)}
917 hint={
918 stats.projectsThisMonth == null
919 ? "in this workspace"
920 : stats.projectsThisMonth > 0
921 ? `+${stats.projectsThisMonth} this month`
922 : "none new this month"
923 }
924 />
925 <Stat
926 label="Agents"
927 value={loaded.runsLoaded || !workspace ? String(liveTotal) : "—"}
928 dot={liveTotal > 0 ? "bg-success animate-pulse" : undefined}
929 hint={
930 liveTotal > 0
931 ? `live now · ${stats.agentHours < 10 ? stats.agentHours.toFixed(1) : Math.round(stats.agentHours)}h this week`
932 : stats.agentHours > 0
933 ? `none live · ${stats.agentHours < 10 ? stats.agentHours.toFixed(1) : Math.round(stats.agentHours)}h this week`
934 : "none live now"
935 }
936 />
937 <Stat
938 label="Changes this week"
939 value={loaded.perRepoLoaded ? String(week.total) : "—"}
940 hint={
941 delta != null ? `${signedPercent(delta)} vs last week` : week.previous === 0 ? "none the week before" : "merged pull requests"
942 }
943 />
944 <Stat
945 label="Landed without you"
946 dot="bg-accent"
947 value={share == null ? "—" : `${Math.round(share * 100)}%`}
948 hint={share == null ? "no agent changes yet" : `${week.byAgents} of ${week.agentChanges} agent changes`}
949 title="Of the changes agents wrote, those g1t merged by auto-merge or the merge queue, with no person pressing merge. People's own changes are not counted."
950 />
951 <div className="col-span-2 lg:col-span-1">
952 <Stat
953 label="Needs you"
954 dot="bg-warn"
955 value={String(needs.length)}
956 hint={blocking > 0 ? `${blocking} blocking` : needs.length > 0 ? "nothing blocking" : "all clear"}
957 />
958 </div>
959 </section>
960
961 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
962 <section id="work" className="min-w-0 scroll-mt-20 self-start overflow-hidden rounded-xl border border-line bg-surface">
963 <div className="flex items-center gap-2 border-b border-line px-2 sm:px-3">
964 <nav className="-mb-px flex min-w-0 grow gap-1 overflow-x-auto [scrollbar-width:none]" aria-label="Mission control">
965 {(["needs", "waiting", "landed"] as const).map((value) => (
966 <Link
967 key={value}
968 to={link({ tab: value, all: null })}
969 preventScrollReset
970 aria-current={tab === value ? "page" : undefined}
971 className={cn(
972 "flex shrink-0 items-center gap-1.5 border-b-2 px-2 py-3 text-sm whitespace-nowrap transition-colors sm:px-2.5",
973 tab === value ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
974 )}
975 >
976 <span className="sm:hidden">{TAB_SHORT[value]}</span>
977 <span className="hidden sm:inline">{TAB_LABEL[value]}</span>
978 <span
979 className={cn(
980 "rounded-full px-1.5 text-[0.6875rem] tabular-nums",
981 tab === value && value === "needs" && counts.needs > 0 ? "bg-warn/15 text-warn" : "bg-line text-muted",
982 )}
983 >
984 {counts[value]}
985 </span>
986 </Link>
987 ))}
988 </nav>
989 <DropdownMenu>
990 <DropdownMenuTrigger
991 className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted hover:bg-raised hover:text-fg"
992 aria-label="Sort"
993 >
994 <ArrowDownWideNarrow size={14} />
995 <span className="hidden sm:inline">{SORT_LABEL[sort]}</span>
996 </DropdownMenuTrigger>
997 <DropdownMenuContent align="end">
998 {(["impact", "newest"] as const).map((value) => (
999 <DropdownMenuItem key={value} asChild>
1000 <Link to={link({ sort: value === "impact" ? null : value })} preventScrollReset>
1001 {sort === value ? <Check /> : <span className="size-4" />}
1002 {SORT_LABEL[value]}
1003 </Link>
1004 </DropdownMenuItem>
1005 ))}
1006 </DropdownMenuContent>
1007 </DropdownMenu>
1008 </div>
1009
1010 {!loaded.perRepoLoaded && tab !== "waiting" ? (
1011 <div className="p-4">
1012 <Unavailable what={tab === "needs" ? "Some of what needs you" : "What landed"} />
1013 </div>
1014 ) : null}
1015
1016 {tab === "needs" &&
1017 (needs.length === 0 ? (
1018 noProjects ? (
1019 <Empty
1020 action={
1021 <Link
1022 to={workspace ? `/new?workspace=${workspace}` : "/new"}
1023 className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-white"
1024 >
1025 Create or import a project
1026 </Link>
1027 }
1028 >
1029 <p className="font-medium text-fg">No projects yet</p>
1030 <p className="mt-1">
1031 Create a repository or import one. Assign its issues to g1t, and what needs you shows up here.
1032 </p>
1033 </Empty>
1034 ) : (
1035 <>
1036 <Empty>
1037 <p className="font-medium text-fg">Nothing needs you</p>
1038 <p className="mt-1">
1039 Agents are handling everything.{" "}
1040 {landed.length > 0
1041 ? "Here's what they landed today."
1042 : "Reviews, stopped work and failed deploys show up here first."}
1043 </p>
1044 </Empty>
1045 {landed.length > 0 && (
1046 <div className="border-t border-line">
1047 <List>
1048 {landed.slice(0, ROWS).map((row) => (
1049 <LandedCard key={row.key} row={row} first={false} />
1050 ))}
1051 </List>
1052 </div>
1053 )}
1054 </>
1055 )
1056 ) : (
1057 <List>
1058 {(shown as NeedRow[]).map((row, index) => (
1059 <NeedCard key={row.key} row={row} first={index === 0} />
1060 ))}
1061 </List>
1062 ))}
1063
1064 {tab === "waiting" &&
1065 (!loaded.runsLoaded && workspace ? (
1066 <div className="p-4">
1067 <Unavailable what="Agent runs" />
1068 </div>
1069 ) : waiting.length === 0 ? (
1070 <Empty>
1071 <p className="font-medium text-fg">No agent is at work right now</p>
1072 <p className="mt-1">Assign an issue to g1t and one starts on it in seconds. Its run shows here while it works.</p>
1073 </Empty>
1074 ) : (
1075 <List>
1076 {(shown as WaitingRow[]).map((row, index) => (
1077 <WaitingCard key={row.key} row={row} first={index === 0} />
1078 ))}
1079 </List>
1080 ))}
1081
1082 {tab === "landed" &&
1083 loaded.perRepoLoaded &&
1084 (landed.length === 0 ? (
1085 <Empty>
1086 <p className="font-medium text-fg">Nothing has landed today yet</p>
1087 <p className="mt-1">
1088 {week.total > 0
1089 ? `${plural(week.total, "change")} landed in the last 7 days.`
1090 : "Merged pull requests show up here the moment they land."}
1091 </p>
1092 </Empty>
1093 ) : (
1094 <List>
1095 {(shown as LandedRow[]).map((row, index) => (
1096 <LandedCard key={row.key} row={row} first={index === 0} />
1097 ))}
1098 </List>
1099 ))}
1100
1101 {rows.length > ROWS && (
1102 <div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted sm:px-5">
1103 <span>
1104 Showing {shown.length} of {rows.length}
1105 </span>
1106 <Link
1107 to={link({ all: all ? null : "1" })}
1108 preventScrollReset
1109 className="inline-flex items-center gap-1 font-medium text-fg-soft hover:text-fg"
1110 >
1111 {all ? "Show fewer" : "See all"} <ChevronRight size={12} />
1112 </Link>
1113 </div>
1114 )}
1115 </section>
1116
1117 <aside className="min-w-0 space-y-6">
1118 <InboxNeedsCard items={loaderData.inboxNeeds.items} total={loaderData.inboxNeeds.total} />
1119 <section className="rounded-xl border border-line bg-surface p-5">
1120 <div className="flex items-center justify-between">
1121 <h2 className="text-base font-semibold tracking-tight">This week</h2>
1122 {workspace && (
1123 <Link to={`/${workspace}/-/agents`} className="text-xs text-muted hover:text-fg">
1124 Fleet
1125 </Link>
1126 )}
1127 </div>
1128 <div className="mt-4">{loaded.perRepoLoaded ? <WeekChart week={week} /> : <Unavailable what="The week" />}</div>
1129 {workspace && stats.weekCost > 0 && (
1130 <Link
1131 to={`/${workspace}/-/usage`}
1132 className="mt-4 flex items-center justify-between border-t border-line pt-3 text-xs text-muted hover:text-fg"
1133 >
1134 <span>Usage at price this week: {usd(stats.weekCost)}</span>
1135 <ChevronRight size={12} />
1136 </Link>
1137 )}
1138 </section>
1139
1140 {workspace && (
1141 <TokenUsagePanel
1142 workspace={loaderData.tokens.workspace}
1143 mine={loaderData.tokens.mine}
1144 usageHref={`/${workspace}/-/usage`}
1145 />
1146 )}
1147
1148 <section id="activity" className="scroll-mt-20 rounded-xl border border-line bg-surface p-5">
1149 <div className="flex items-center justify-between">
1150 <h2 className="text-base font-semibold tracking-tight">Activity</h2>
1151 {groups.length > 8 && (
1152 <Link
1153 to={`${link({ activity: everyActivity ? null : "all" })}#activity`}
1154 preventScrollReset
1155 className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg"
1156 >
1157 {everyActivity ? "Less" : "All activity"} <ChevronRight size={12} />
1158 </Link>
1159 )}
1160 </div>
1161 <p className="mt-1 flex items-center gap-3 text-[0.6875rem] text-faint">
1162 <span className="inline-flex items-center gap-1.5">
1163 <Avatar name="g1t" size={12} /> agents
1164 </span>
1165 <span className="inline-flex items-center gap-1.5">
1166 <Avatar name={viewer.username} size={12} /> people
1167 </span>
1168 </p>
1169 {!loaded.perRepoLoaded ? (
1170 <div className="mt-3">
1171 <Unavailable what="Activity" />
1172 </div>
1173 ) : feed.length === 0 ? (
1174 <p className="mt-3 text-sm leading-6 text-muted">
1175 Nothing has moved yet. Merges, deploys, checks, reviews and what agents learn show up here.
1176 </p>
1177 ) : (
1178 <ol className="mt-2 divide-y divide-line/70">
1179 {feed.map((group) => (
1180 <FeedLine key={group.id} group={group} titles={titles} />
1181 ))}
1182 </ol>
1183 )}
1184 </section>
1185
1186 <section className="rounded-xl border border-line bg-surface p-5">
1187 <h2 className="text-sm font-semibold">Connect your own agent</h2>
1188 <p className="mt-1.5 text-xs leading-5 text-muted">
1189 Add g1t to your coding agent. It signs in through your browser; there is no token to copy.
1190 </p>
1191 <AgentSetup className="mt-3" />
1192 <Link
1193 to="https://docs.g1t.sh/guides/bring-your-own-agent/"
1194 className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"
1195 >
1196 How it works <ArrowRight size={12} />
1197 </Link>
1198 </section>
1199 </aside>
1200 </div>
1201 </main>
1202 );
1203}