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

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