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