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

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