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

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