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