Skip to content
1,204 linesCodeBlameRaw
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 pushed: "pushed to the default branch",
580 learned: "learned",
581};
582
583/** One line of the feed: who, what, and the one thing it was about, by name. */
584function FeedLine({ group, titles }: { group: ActivityGroup; titles: Record<string, string> }) {
585 const part = group.parts[0];
586 const base = `/${group.repo.namespace}/${group.repo.name}`;
587 const actor = group.actor ?? "g1t";
588 const agent = group.actor == null || isAgent(group.actor);
589 const number = part.numbers[0];
590 const title = number != null ? titles[`${group.repo.namespace}/${group.repo.name}#${number}`.toLowerCase()] : undefined;
591 const more = group.parts.length - 1 + Math.max(0, part.numbers.length - 1);
592 return (
593 <li className="flex gap-2.5 py-2">
594 <span className="mt-px">
595 <Avatar name={actor} size={22} />
596 </span>
597 <span className="min-w-0 grow text-[0.8125rem] leading-5 text-muted">
598 <span className={cn("font-medium", agent ? "text-accent" : "text-fg")}>{actor}</span> {VERB[part.verb]}{" "}
599 {part.verb === "learned" ? (
600 <Link to={part.to ?? `${base}/memory`} className="text-fg-soft hover:text-fg">
601 “{part.texts[0]}”
602 </Link>
603 ) : number != null ? (
604 <Link to={`${base}/issues/${number}`} prefetch="intent" className="text-fg-soft hover:text-fg">
605 <span className="font-mono text-xs">#{number}</span>
606 {title && <> {title}</>}
607 </Link>
608 ) : part.to ? (
609 <Link to={part.to} className="text-fg-soft hover:text-fg">
610 {group.repo.name}
611 </Link>
612 ) : null}
613 {more > 0 && <span className="text-faint"> and {more} more</span>}
614 <span className="block text-xs text-faint">
615 <span className="font-mono">{group.repo.name}</span> · <TimeAgo at={group.at} />
616 </span>
617 </span>
618 </li>
619 );
620}
621
622// --- Get started --------------------------------------------------------------------
623
624type Step = { done: boolean; title: string; about: string; to: string | null; action: string };
625
626/** The first things to do, ticked off as they are done, until work has been handed to agents. */
627function GetStarted({ steps }: { steps: Step[] }) {
628 const left = steps.filter((step) => !step.done).length;
629 return (
630 <section className="rounded-xl border border-line bg-surface p-5">
631 <div className="flex items-baseline justify-between gap-3">
632 <h2 className="text-base font-semibold tracking-tight">Get started</h2>
633 <span className="text-xs text-muted">
634 {steps.length - left} of {steps.length} done
635 </span>
636 </div>
637 <ol className="mt-4 space-y-2">
638 {steps.map((step, index) => (
639 <li
640 key={step.title}
641 className={cn("flex items-start gap-3 rounded-lg px-3 py-2.5", step.done ? "" : "bg-bg/50 ring-1 ring-line")}
642 >
643 <span
644 className={cn(
645 "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-medium",
646 step.done ? "bg-success text-bg" : "text-muted ring-1 ring-line-strong",
647 )}
648 >
649 {step.done ? <Check size={12} /> : index + 1}
650 </span>
651 <span className="min-w-0 grow">
652 <span className={cn("block text-sm font-medium", step.done && "text-muted line-through decoration-faint")}>{step.title}</span>
653 {!step.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{step.about}</span>}
654 </span>
655 {!step.done && step.to && (
656 <Link
657 to={step.to}
658 className="shrink-0 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg transition-colors hover:bg-white"
659 >
660 {step.action}
661 </Link>
662 )}
663 </li>
664 ))}
665 </ol>
666 </section>
667 );
668}
669
670// --- The page -----------------------------------------------------------------------
671
672const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting on agents", landed: "Landed today" };
673const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting", landed: "Today" };
674const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" };
675
676export default function MissionControl({ loaderData, delegated = null }: { loaderData: Loaded; delegated?: ComposerResult }) {
677 const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined;
678 const loaded: Loaded = loaderData;
679 const [params] = useSearchParams();
680 // Agents are at work, so the page changes without anyone touching it.
681 useLiveRefresh(loaded.changing);
682 const [greeting, setGreeting] = useState(loaded.greeting);
683 const [date, setDate] = useState(loaded.date);
684 useEffect(() => {
685 // The greeting and date follow the viewer's own clock, and the server
686 // learns their zone for next time.
687 setGreeting(greetingFor(new Date().getHours()));
688 try {
689 const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
690 setDate(dateLine(Date.now(), zone ?? null));
691 if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`;
692 } catch {
693 // Nothing to remember.
694 }
695 }, []);
696
697 const { viewer, repos, waiting, landed, liveTotal, week, groups, titles, stats, canRunAgents, trial } = loaded;
698 const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null;
699
700 // The usage limit, as the sidebar knows it, heads the list when it bites.
701 const limit = shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace ? shell.limit : null;
702 const limitRow: NeedRow[] = limit
703 ? [
704 (() => {
705 const detail =
706 limit.state === "stopped"
707 ? "No new agent starts until the limit is raised or a card is added."
708 : `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`;
709 return {
710 key: "limit",
711 reason: "blocking" as const,
712 repo: null,
713 ref: null,
714 title: limit.state === "stopped" ? "Agents are stopped: the usage limit was reached" : "Usage is nearing its limit",
715 ask: detail,
716 by: null,
717 for: null,
718 at: Date.now(),
719 to: `/${workspace}/-/billing`,
720 open: `/${workspace}/-/billing`,
721 facts: [
722 { label: "Used", value: `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)}`, tone: "warn" as const },
723 ],
724 why: whyFor("blocking", { kind: "limit", detail }),
725 quick: null,
726 link: null,
727 };
728 })(),
729 ]
730 : [];
731 const needs = [...limitRow, ...loaded.needs];
732 const blocking = needs.filter((row) => BLOCKING.has(row.reason)).length;
733
734 const counts: Record<Tab, number> = { needs: needs.length, waiting: waiting.length, landed: landed.length };
735 const tab = parseTab(params.get("tab")) ?? "needs";
736 const sort = parseSort(params.get("sort"));
737 const all = params.get("all") === "1";
738 const everyActivity = params.get("activity") === "all";
739 const link = (changes: Record<string, string | null>) => {
740 const next = new URLSearchParams(params);
741 for (const [key, value] of Object.entries(changes)) {
742 if (value == null) next.delete(key);
743 else next.set(key, value);
744 }
745 const text = next.toString();
746 return text ? `?${text}` : "/";
747 };
748
749 const steps: Step[] = [
750 {
751 done: Boolean(workspace),
752 title: "Create a workspace",
753 about: "Projects, people and agent credit live in one.",
754 to: "/workspaces/new",
755 action: "Create",
756 },
757 {
758 done: repos.length > 0,
759 title: "Add a project",
760 about: "Create a repository, or import one by its address. Push to it with git as usual.",
761 to: workspace ? `/new?workspace=${workspace}` : "/new",
762 action: "Add",
763 },
764 trial?.open
765 ? {
766 done: true,
767 title: "Try g1t free",
768 about: trial.granted
769 ? `This workspace has ${dollars(trial.limitMicros - trial.usedMicros)} of its ${dollars(trial.limitMicros)} trial credit left, for g1t's models and sandboxes.`
770 : `This workspace gets ${dollars(trial.limitMicros)} of trial credit, for g1t's models and sandboxes, the first time g1t works here.`,
771 to: workspace ? `/${workspace}/-/integrations` : null,
772 action: "Connect",
773 }
774 : canRunAgents
775 ? {
776 done: Boolean(shell?.free) || shell?.limit == null || shell.limit.comped || shell.limit.state === "ok",
777 title: "Keep work running",
778 about:
779 "Usage is charged after it runs. Add a card under Billing, so g1t charges it as you near your limit instead of stopping work.",
780 to: workspace ? `/${workspace}/-/billing` : null,
781 action: "Billing",
782 }
783 : {
784 done: false,
785 title: "Connect a model",
786 about: `${
787 trialClosed(trial, workspace ?? "This workspace") ? `${trialClosed(trial, workspace ?? "This workspace")} ` : ""
788 }Agents need a model to think with. Connect your Anthropic or OpenAI key, or any compatible endpoint.`,
789 to: workspace ? `/${workspace}/-/integrations` : null,
790 action: "Connect",
791 },
792 {
793 done: loaded.handedOff,
794 title: "Hand off an outcome",
795 about:
796 "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.",
797 to: repos[0] ? `/${repos[0].namespace}/${repos[0].name}/plans` : null,
798 action: "Write one",
799 },
800 ];
801 const starting = steps.some((step) => !step.done);
802 const noProjects = repos.length === 0;
803
804 const rows = tab === "needs" ? sortRows(needs, sort) : tab === "waiting" ? sortRows(waiting, sort) : sortRows(landed, sort);
805 const shown = all ? rows : rows.slice(0, ROWS);
806 // Of the agents' own changes only: people's work is not theirs to land.
807 const share = week.agentChanges > 0 ? week.byAgents / week.agentChanges : null;
808 const delta = change(week.total, week.previous);
809 const feed = everyActivity ? groups : groups.slice(0, 8);
810
811 // "Put an agent on it" first, with "New issue" beside it as before: one
812 // split control, the agent the main way in.
813 const newIssue =
814 repos.length > 0 ? (
815 <div className="flex items-stretch">
816 <AgentComposer
817 repos={repos}
818 open={params.get("agent") === "new" || delegated != null}
819 result={delegated}
820 note={
821 canRunAgents ? null : (
822 <>
823 Agents need a model first.{" "}
824 {workspace && (
825 <Link to={`/${workspace}/-/integrations`} className="font-medium text-fg hover:underline">
826 Connect one
827 </Link>
828 )}
829 . The issue still opens.
830 </>
831 )
832 }
833 >
834 <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">
835 <Sparkles size={14} className="text-accent" /> Put an agent on it
836 </span>
837 </AgentComposer>
838 <DropdownMenu>
839 <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">
840 <Plus size={14} /> New issue
841 </DropdownMenuTrigger>
842 <DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
843 <DropdownMenuLabel>In which project?</DropdownMenuLabel>
844 {repos.map((repo) => (
845 <DropdownMenuItem key={repo.name} asChild>
846 <Link to={`/${repo.namespace}/${repo.name}/issues/new`}>
847 <Avatar name={repo.name} size={18} square />
848 {repo.name}
849 </Link>
850 </DropdownMenuItem>
851 ))}
852 </DropdownMenuContent>
853 </DropdownMenu>
854 </div>
855 ) : (
856 <Link
857 to={workspace ? `/new?workspace=${workspace}` : "/new"}
858 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"
859 >
860 <Plus size={14} /> New project
861 </Link>
862 );
863
864 return (
865 <main className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
866 <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
867 <div className="min-w-0">
868 <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight sm:text-[2rem]" suppressHydrationWarning>
869 {greeting}, {loaded.name}
870 </h1>
871 <p className="mt-1.5 text-sm text-muted" suppressHydrationWarning>
872 <span className="text-fg-soft">{date}</span> · {loaded.summary}
873 </p>
874 </div>
875 </header>
876
877 {/* The agent is not on yet; the actions under it do the work today. */}
878 <AskComposer>
879 {newIssue}
880 {repos.length > 0 && (
881 <DropdownMenu>
882 <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">
883 <ListTree size={14} /> Plan work
884 </DropdownMenuTrigger>
885 <DropdownMenuContent align="start" className="max-h-80 overflow-y-auto">
886 <DropdownMenuLabel>Plan in which project?</DropdownMenuLabel>
887 {repos.map((repo) => (
888 <DropdownMenuItem key={repo.name} asChild>
889 <Link to={`/${repo.namespace}/${repo.name}/plans`}>
890 <Avatar name={repo.name} size={18} square />
891 {repo.name}
892 </Link>
893 </DropdownMenuItem>
894 ))}
895 </DropdownMenuContent>
896 </DropdownMenu>
897 )}
898 {needs.length > 0 && (
899 <Link
900 to={`${link({ tab: "needs", all: null })}#work`}
901 preventScrollReset
902 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"
903 >
904 Review {needs.length} that need{needs.length === 1 ? "s" : ""} you <ArrowRight size={14} />
905 </Link>
906 )}
907 </AskComposer>
908
909 {starting && <GetStarted steps={steps} />}
910
911 <section
912 aria-label="At a glance"
913 className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-5"
914 >
915 <Stat
916 label="Projects"
917 value={stats.projects == null ? "—" : String(stats.projects)}
918 hint={
919 stats.projectsThisMonth == null
920 ? "in this workspace"
921 : stats.projectsThisMonth > 0
922 ? `+${stats.projectsThisMonth} this month`
923 : "none new this month"
924 }
925 />
926 <Stat
927 label="Agents"
928 value={loaded.runsLoaded || !workspace ? String(liveTotal) : "—"}
929 dot={liveTotal > 0 ? "bg-success animate-pulse" : undefined}
930 hint={
931 liveTotal > 0
932 ? `live now · ${stats.agentHours < 10 ? stats.agentHours.toFixed(1) : Math.round(stats.agentHours)}h this week`
933 : stats.agentHours > 0
934 ? `none live · ${stats.agentHours < 10 ? stats.agentHours.toFixed(1) : Math.round(stats.agentHours)}h this week`
935 : "none live now"
936 }
937 />
938 <Stat
939 label="Changes this week"
940 value={loaded.perRepoLoaded ? String(week.total) : "—"}
941 hint={
942 delta != null ? `${signedPercent(delta)} vs last week` : week.previous === 0 ? "none the week before" : "merged pull requests"
943 }
944 />
945 <Stat
946 label="Landed without you"
947 dot="bg-accent"
948 value={share == null ? "—" : `${Math.round(share * 100)}%`}
949 hint={share == null ? "no agent changes yet" : `${week.byAgents} of ${week.agentChanges} agent changes`}
950 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."
951 />
952 <div className="col-span-2 lg:col-span-1">
953 <Stat
954 label="Needs you"
955 dot="bg-warn"
956 value={String(needs.length)}
957 hint={blocking > 0 ? `${blocking} blocking` : needs.length > 0 ? "nothing blocking" : "all clear"}
958 />
959 </div>
960 </section>
961
962 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
963 <section id="work" className="min-w-0 scroll-mt-20 self-start overflow-hidden rounded-xl border border-line bg-surface">
964 <div className="flex items-center gap-2 border-b border-line px-2 sm:px-3">
965 <nav className="-mb-px flex min-w-0 grow gap-1 overflow-x-auto [scrollbar-width:none]" aria-label="Mission control">
966 {(["needs", "waiting", "landed"] as const).map((value) => (
967 <Link
968 key={value}
969 to={link({ tab: value, all: null })}
970 preventScrollReset
971 aria-current={tab === value ? "page" : undefined}
972 className={cn(
973 "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",
974 tab === value ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
975 )}
976 >
977 <span className="sm:hidden">{TAB_SHORT[value]}</span>
978 <span className="hidden sm:inline">{TAB_LABEL[value]}</span>
979 <span
980 className={cn(
981 "rounded-full px-1.5 text-[0.6875rem] tabular-nums",
982 tab === value && value === "needs" && counts.needs > 0 ? "bg-warn/15 text-warn" : "bg-line text-muted",
983 )}
984 >
985 {counts[value]}
986 </span>
987 </Link>
988 ))}
989 </nav>
990 <DropdownMenu>
991 <DropdownMenuTrigger
992 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"
993 aria-label="Sort"
994 >
995 <ArrowDownWideNarrow size={14} />
996 <span className="hidden sm:inline">{SORT_LABEL[sort]}</span>
997 </DropdownMenuTrigger>
998 <DropdownMenuContent align="end">
999 {(["impact", "newest"] as const).map((value) => (
1000 <DropdownMenuItem key={value} asChild>
1001 <Link to={link({ sort: value === "impact" ? null : value })} preventScrollReset>
1002 {sort === value ? <Check /> : <span className="size-4" />}
1003 {SORT_LABEL[value]}
1004 </Link>
1005 </DropdownMenuItem>
1006 ))}
1007 </DropdownMenuContent>
1008 </DropdownMenu>
1009 </div>
1010
1011 {!loaded.perRepoLoaded && tab !== "waiting" ? (
1012 <div className="p-4">
1013 <Unavailable what={tab === "needs" ? "Some of what needs you" : "What landed"} />
1014 </div>
1015 ) : null}
1016
1017 {tab === "needs" &&
1018 (needs.length === 0 ? (
1019 noProjects ? (
1020 <Empty
1021 action={
1022 <Link
1023 to={workspace ? `/new?workspace=${workspace}` : "/new"}
1024 className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-white"
1025 >
1026 Create or import a project
1027 </Link>
1028 }
1029 >
1030 <p className="font-medium text-fg">No projects yet</p>
1031 <p className="mt-1">
1032 Create a repository or import one. Assign its issues to g1t, and what needs you shows up here.
1033 </p>
1034 </Empty>
1035 ) : (
1036 <>
1037 <Empty>
1038 <p className="font-medium text-fg">Nothing needs you</p>
1039 <p className="mt-1">
1040 Agents are handling everything.{" "}
1041 {landed.length > 0
1042 ? "Here's what they landed today."
1043 : "Reviews, stopped work and failed deploys show up here first."}
1044 </p>
1045 </Empty>
1046 {landed.length > 0 && (
1047 <div className="border-t border-line">
1048 <List>
1049 {landed.slice(0, ROWS).map((row) => (
1050 <LandedCard key={row.key} row={row} first={false} />
1051 ))}
1052 </List>
1053 </div>
1054 )}
1055 </>
1056 )
1057 ) : (
1058 <List>
1059 {(shown as NeedRow[]).map((row, index) => (
1060 <NeedCard key={row.key} row={row} first={index === 0} />
1061 ))}
1062 </List>
1063 ))}
1064
1065 {tab === "waiting" &&
1066 (!loaded.runsLoaded && workspace ? (
1067 <div className="p-4">
1068 <Unavailable what="Agent runs" />
1069 </div>
1070 ) : waiting.length === 0 ? (
1071 <Empty>
1072 <p className="font-medium text-fg">No agent is at work right now</p>
1073 <p className="mt-1">Assign an issue to g1t and one starts on it in seconds. Its run shows here while it works.</p>
1074 </Empty>
1075 ) : (
1076 <List>
1077 {(shown as WaitingRow[]).map((row, index) => (
1078 <WaitingCard key={row.key} row={row} first={index === 0} />
1079 ))}
1080 </List>
1081 ))}
1082
1083 {tab === "landed" &&
1084 loaded.perRepoLoaded &&
1085 (landed.length === 0 ? (
1086 <Empty>
1087 <p className="font-medium text-fg">Nothing has landed today yet</p>
1088 <p className="mt-1">
1089 {week.total > 0
1090 ? `${plural(week.total, "change")} landed in the last 7 days.`
1091 : "Merged pull requests show up here the moment they land."}
1092 </p>
1093 </Empty>
1094 ) : (
1095 <List>
1096 {(shown as LandedRow[]).map((row, index) => (
1097 <LandedCard key={row.key} row={row} first={index === 0} />
1098 ))}
1099 </List>
1100 ))}
1101
1102 {rows.length > ROWS && (
1103 <div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted sm:px-5">
1104 <span>
1105 Showing {shown.length} of {rows.length}
1106 </span>
1107 <Link
1108 to={link({ all: all ? null : "1" })}
1109 preventScrollReset
1110 className="inline-flex items-center gap-1 font-medium text-fg-soft hover:text-fg"
1111 >
1112 {all ? "Show fewer" : "See all"} <ChevronRight size={12} />
1113 </Link>
1114 </div>
1115 )}
1116 </section>
1117
1118 <aside className="min-w-0 space-y-6">
1119 <InboxNeedsCard items={loaderData.inboxNeeds.items} total={loaderData.inboxNeeds.total} />
1120 <section className="rounded-xl border border-line bg-surface p-5">
1121 <div className="flex items-center justify-between">
1122 <h2 className="text-base font-semibold tracking-tight">This week</h2>
1123 {workspace && (
1124 <Link to={`/${workspace}/-/agents`} className="text-xs text-muted hover:text-fg">
1125 Fleet
1126 </Link>
1127 )}
1128 </div>
1129 <div className="mt-4">{loaded.perRepoLoaded ? <WeekChart week={week} /> : <Unavailable what="The week" />}</div>
1130 {workspace && stats.weekCost > 0 && (
1131 <Link
1132 to={`/${workspace}/-/usage`}
1133 className="mt-4 flex items-center justify-between border-t border-line pt-3 text-xs text-muted hover:text-fg"
1134 >
1135 <span>Usage at price this week: {usd(stats.weekCost)}</span>
1136 <ChevronRight size={12} />
1137 </Link>
1138 )}
1139 </section>
1140
1141 {workspace && (
1142 <TokenUsagePanel
1143 workspace={loaderData.tokens.workspace}
1144 mine={loaderData.tokens.mine}
1145 usageHref={`/${workspace}/-/usage`}
1146 />
1147 )}
1148
1149 <section id="activity" className="scroll-mt-20 rounded-xl border border-line bg-surface p-5">
1150 <div className="flex items-center justify-between">
1151 <h2 className="text-base font-semibold tracking-tight">Activity</h2>
1152 {groups.length > 8 && (
1153 <Link
1154 to={`${link({ activity: everyActivity ? null : "all" })}#activity`}
1155 preventScrollReset
1156 className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg"
1157 >
1158 {everyActivity ? "Less" : "All activity"} <ChevronRight size={12} />
1159 </Link>
1160 )}
1161 </div>
1162 <p className="mt-1 flex items-center gap-3 text-[0.6875rem] text-faint">
1163 <span className="inline-flex items-center gap-1.5">
1164 <Avatar name="g1t" size={12} /> agents
1165 </span>
1166 <span className="inline-flex items-center gap-1.5">
1167 <Avatar name={viewer.username} size={12} /> people
1168 </span>
1169 </p>
1170 {!loaded.perRepoLoaded ? (
1171 <div className="mt-3">
1172 <Unavailable what="Activity" />
1173 </div>
1174 ) : feed.length === 0 ? (
1175 <p className="mt-3 text-sm leading-6 text-muted">
1176 Nothing has moved yet. Merges, deploys, checks, reviews and what agents learn show up here.
1177 </p>
1178 ) : (
1179 <ol className="mt-2 divide-y divide-line/70">
1180 {feed.map((group) => (
1181 <FeedLine key={group.id} group={group} titles={titles} />
1182 ))}
1183 </ol>
1184 )}
1185 </section>
1186
1187 <section className="rounded-xl border border-line bg-surface p-5">
1188 <h2 className="text-sm font-semibold">Connect your own agent</h2>
1189 <p className="mt-1.5 text-xs leading-5 text-muted">
1190 Add g1t to your coding agent. It signs in through your browser; there is no token to copy.
1191 </p>
1192 <AgentSetup className="mt-3" />
1193 <Link
1194 to="https://docs.g1t.sh/guides/bring-your-own-agent/"
1195 className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"
1196 >
1197 How it works <ArrowRight size={12} />
1198 </Link>
1199 </section>
1200 </aside>
1201 </div>
1202 </main>
1203 );
1204}