Skip to content

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

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