g1t/apps/web/app/routes/repo/overview.tsx

1,091 lines50,172 bytesCodeBlame
1import {
2 Activity as ActivityIcon,
3 ArrowDownLeft,
4 ArrowRight,
5 ArrowUpRight,
6 Bot,
7 Brain,
8 Code2,
9 GitBranch,
10 GitCommitHorizontal,
11 GitMerge,
12 Hand,
13 HeartPulse,
14 Loader2,
15 Lock,
16 Network,
17 Pin,
18 Rocket,
19 RotateCw,
20} from "lucide-react";
21import { type ReactNode, Suspense } from "react";
22import { Await, Form, Link, useNavigation } from "react-router";
23
24import { type AgentRun, type G1tEvent, type Memory, type Pull, RUN_KIND_LABEL, isActiveRun, workOwner } from "@g1t/contracts";
25
26import type { Route } from "./+types/overview";
27import { host, StatusDot } from "../../components/deploy";
28import { CheckBadge } from "../../components/checks";
29import { Elapsed, formatCost, useLiveRefresh } from "../../components/agents";
30import { ActivityFeed, DeployStrip, Meter, NeedsList, Panel, Quiet, Unavailable, percent } from "../../components/mission";
31import { type ActiveBranch, ActiveBranches } from "../../components/branches";
32import { ProductionChecklist } from "../../components/checklist";
33import { Skeleton, SkeletonRows } from "../../components/ui/skeleton";
34import { ProductionShot } from "../../components/production-shot";
35import { GithubLinkStrip } from "../../components/github";
36import { githubApp } from "../../lib/github.server";
37import { Avatar, Button, ButtonLink, CopyLine, TimeAgo } from "../../components/ui";
38import { ChangeSize } from "../../components/work";
39import {
40 type ActivityItem,
41 type Need,
42 type PipelineStage,
43 PIPELINE,
44 TIME,
45 ageBuckets,
46 eventItem,
47 firstPassRate,
48 groupActivity,
49 passRate,
50 pipelineStage,
51 queuedNumbers,
52 rankNeeds,
53 stuckMinutes,
54} from "../../lib/mission";
55import { drift } from "../../lib/branches";
56import { agentWasAssigned, hasInstructions, productionChecklist } from "../../lib/checklist";
57import { agents, deployments, events as eventLog, projects, repos, work } from "../../lib/services.server";
58import { madeByG1t } from "../../lib/opened-by";
59import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
60import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server";
61import { shotVersion } from "./production-screenshot";
62
63const MAX_LANDED = 6;
64/** Branches read for the Active branches list, and shown. */
65const BRANCHES_READ = 10;
66const BRANCHES_SHOWN = 5;
67/** How far back each branch's history, and the default branch's, is read to count ahead and behind. */
68const BRANCH_DEPTH = 40;
69const MAIN_DEPTH = 120;
70
71/**
72 * The overview streams: the layout's header and tabs (one repository
73 * lookup, one project) go out at once with a skeleton below them, and
74 * everything here, seventeen calls across six services, follows in the
75 * same response as it settles. Crawlers wait for all of it
76 * (entry.server.tsx). Before, the first byte waited on the slowest of them.
77 */
78export function loader({ params, context }: Route.LoaderArgs) {
79 return { overview: overviewData({ params, context }) };
80}
81
82async function overviewData({ params, context }: Pick<Route.LoaderArgs, "params" | "context">) {
83 const viewer = getViewer(context);
84 const path = { namespace: params.owner, name: params.repo };
85 const ref = { workspace: params.owner, slug: params.repo };
86 const now = Date.now();
87 // A section whose service fails shows its own empty state; the page stays up.
88 const soft = <T,>(promise: Promise<T> | null): Promise<T | null> =>
89 promise ? promise.catch((error) => (console.warn("overview:", error), null)) : Promise.resolve(null);
90 // People with a role of their own here see its running parts: deployments,
91 // memory, setup; everyone who can read it sees the rest. Not awaited: only
92 // those sections wait on the repository lookup, which the layout makes in
93 // this request too.
94 const accessP = accessTo(context, params);
95 const memberP = accessP.then((access) => access.insider);
96 const forMembers = <T,>(start: () => Promise<T>): Promise<T | null> =>
97 memberP.then((member) => (member ? soft(start()) : null));
98 const repoP = soft(repoFor(context, params));
99 const eventsP = repoP.then((repo) => (repo?.ok ? soft(eventLog.list({ repoId: repo.value.id, limit: 150 })) : null));
100 const minePullsP = repoP.then((repo) =>
101 repo?.ok && viewer
102 ? soft(work.listActivePulls(viewer)).then((list) => (list ?? []).filter((item) => item.pull.repoId === repo.value.id))
103 : null,
104 );
105 const openP = soft(work.listPulls(path, viewer, "open"));
106 // Where its code came from on GitHub, for members.
107 const githubP = repoP.then((repo) => (repo?.ok ? forMembers(() => githubApp.link(repo.value.id)) : null));
108 const listP = forMembers(() => deployments.list(ref, viewer));
109 // Active branches: the newest few besides the default, with how far each
110 // has moved. Branches with an open pull request are read first.
111 const branchesP = Promise.all([repoP, soft(repos.branches(path, viewer)), openP, listP]).then(
112 async ([repo, branchList, pulls, deploys]): Promise<{ main: string; total: number; shown: ActiveBranch[] } | null> => {
113 if (!repo?.ok || !branchList?.ok) return null;
114 const main = repo.value.defaultBranch;
115 const pullList = pulls?.ok ? pulls.value : [];
116 const pullOn = new Map(pullList.filter((pull) => pull.branch).map((pull) => [pull.branch as string, pull]));
117 const others = branchList.value.filter((branch) => branch.name !== main);
118 if (others.length === 0) return { main, total: 0, shown: [] };
119 const read = [...others.filter((b) => pullOn.has(b.name)), ...others.filter((b) => !pullOn.has(b.name))].slice(0, BRANCHES_READ);
120 // By commit hash, not name: history from a commit never changes, so
121 // repos keeps it (services/repos/src/store.rs) and only new heads cost
122 // a walk.
123 const mainHead = branchList.value.find((branch) => branch.name === main)?.hash ?? main;
124 const [mainLog, ...logs] = await Promise.all([
125 soft(repos.log(path, viewer, mainHead, MAIN_DEPTH)),
126 ...read.map((branch) => soft(repos.log(path, viewer, branch.hash || branch.name, BRANCH_DEPTH))),
127 ]);
128 const mainHashes = mainLog?.ok ? mainLog.value.map((c) => c.hash) : [];
129 const previews = deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [];
130 const shown = read
131 .map((branch, index): ActiveBranch => {
132 const history = logs[index]?.ok ? logs[index].value : [];
133 const head = history[0];
134 const moved = drift(history.map((c) => c.hash), mainHashes, BRANCH_DEPTH);
135 const pull = pullOn.get(branch.name);
136 return {
137 name: branch.name,
138 commit: head ? { hash: head.hash, message: head.message.split("\n")[0], author: head.author.name, at: head.authoredAt } : null,
139 ...moved,
140 pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft" } : null,
141 preview: previews.find((app) => app.branch === branch.name || (pull != null && app.number === pull.number))?.url ?? null,
142 };
143 })
144 .sort((a, b) => Date.parse(b.commit?.at ?? "0") - Date.parse(a.commit?.at ?? "0"))
145 .slice(0, BRANCHES_SHOWN);
146 return { main, total: others.length, shown };
147 },
148 );
149 // Active branches read several logs each: streamed, so the rest shows first.
150 const branches = branchesP.catch(() => null);
151 const [{ insider: member, can }, project, settings, list, open, closed, log, counts, deps, runs, queue, issues, memories, recent, mine, domains, root] = await Promise.all([
152 accessP,
153 soft(projects.get(params.owner, params.repo, viewer)),
154 forMembers(() => deployments.settings(ref, viewer)),
155 listP,
156 openP,
157 soft(work.listPulls(path, viewer, "closed")),
158 soft(repos.log(path, viewer, null, 1)),
159 soft(countsFor(context, params)),
160 soft(projects.dependencies(params.owner, params.repo, viewer)),
161 soft(agents.listRuns(viewer, { repo: path, limit: 60 })),
162 soft(work.queue(path, viewer)),
163 soft(work.listIssues(path, viewer, { state: "open" })),
164 forMembers(() => agents.listMemories(viewer, params.owner, path)),
165 eventsP,
166 minePullsP,
167 // For the checklist, which only members see.
168 forMembers(() => deployments.domains(ref, viewer)),
169 forMembers(() => repos.tree(path, viewer, null, "")),
170 ]);
171 const ok = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
172
173 const openPulls = ok(open) ?? [];
174 const runList = ok(runs) ?? [];
175 const live = runList.filter((run) => isActiveRun(run.status));
176 const builds = ok(list)?.deployments ?? [];
177 const landed = (ok(closed) ?? [])
178 .filter((pull) => pull.status === "merged")
179 .sort((a, b) => Date.parse(b.mergedAt ?? b.updatedAt) - Date.parse(a.mergedAt ?? a.updatedAt));
180 const queued = queuedNumbers(ok(queue)?.active ?? []);
181 const runOn = new Map<number, AgentRun>();
182 for (const run of live) if (run.number != null && !runOn.has(run.number)) runOn.set(run.number, run);
183 const lifecycleOf = new Map((mine ?? []).map((item) => [item.pull.number, item.lifecycle]));
184
185 // --- The pipeline -----------------------------------------------------------
186 type Card = {
187 number: number;
188 title: string;
189 agent: string;
190 status: Pull["status"];
191 checkStatus: Pull["checkStatus"];
192 step: string | null;
193 runKind: string | null;
194 runStarted: string | null;
195 needsYou: boolean;
196 at: string;
197 };
198 const columns: Record<PipelineStage, Card[]> = { working: [], checking: [], reviewing: [], queue: [], landed: [] };
199 for (const pull of openPulls.slice(0, 40)) {
200 const run = runOn.get(pull.number);
201 columns[pipelineStage(pull, run, queued, lifecycleOf.get(pull.number))].push({
202 number: pull.number,
203 title: pull.title,
204 agent: pull.agent,
205 status: pull.status,
206 checkStatus: pull.checkStatus,
207 step: run?.step ?? null,
208 runKind: run ? RUN_KIND_LABEL[run.kind] : null,
209 runStarted: run ? (run.startedAt ?? run.createdAt) : null,
210 needsYou: lifecycleOf.get(pull.number)?.stage === "needs_you",
211 at: pull.updatedAt,
212 });
213 }
214 for (const pull of landed.filter((p) => now - Date.parse(p.mergedAt ?? p.updatedAt) < 3 * TIME.DAY).slice(0, 5)) {
215 columns.landed.push({
216 number: pull.number,
217 title: pull.title,
218 agent: pull.agent,
219 status: pull.status,
220 checkStatus: pull.checkStatus,
221 step: null,
222 runKind: null,
223 runStarted: null,
224 needsYou: false,
225 at: pull.mergedAt ?? pull.updatedAt,
226 });
227 }
228
229 // --- Needs you ---------------------------------------------------------------
230 const base = `/${params.owner}/${params.repo}`;
231 const needs: Need[] = [];
232 const latestProduction = builds.find((build) => build.kind === "production") ?? null;
233 if (latestProduction?.status === "failed") {
234 needs.push({
235 key: "deploy",
236 kind: "deploy",
237 title: "The production build failed",
238 detail: latestProduction.error ?? "Production still serves the build before it.",
239 to: `${base}/deployments/${latestProduction.id}`,
240 action: "See the build",
241 at: Date.parse(latestProduction.finishedAt ?? latestProduction.createdAt),
242 where: latestProduction.commit.slice(0, 7),
243 });
244 }
245 const me = viewer?.username.toLowerCase();
246 for (const pull of openPulls) {
247 const to = `${base}/pull/${pull.number}`;
248 const where = `#${pull.number}`;
249 const lifecycle = lifecycleOf.get(pull.number);
250 if (lifecycle?.stage === "needs_you") {
251 const conflict = /conflict/i.test(lifecycle.detail);
252 needs.push({ key: `pull:${pull.number}`, kind: conflict ? "conflict" : "stalled", title: pull.title, detail: lifecycle.detail, to, action: conflict ? "Resolve" : "Decide", at: Date.parse(pull.updatedAt), where });
253 } else if (lifecycle?.stage === "ready") {
254 needs.push({ key: `pull:${pull.number}`, kind: "ready", title: pull.title, detail: "Ready to land when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where });
255 }
256 // Never your own, nor one g1t made for you; whoever it is for asked.
257 const owner = workOwner(pull).username;
258 if (me && pull.status === "open" && owner.toLowerCase() !== me && pull.reviewers.some((name) => name.toLowerCase() === me)) {
259 needs.push({ key: `review:${pull.number}`, kind: "review", title: pull.title, detail: `${owner} asked for your review.`, to, action: "Review", at: Date.parse(pull.updatedAt), where });
260 }
261 if (member && pull.status === "open" && pull.checkStatus === "failed" && !runOn.has(pull.number) && !lifecycle) {
262 needs.push({ key: `checks:${pull.number}`, kind: "checks", title: pull.title, detail: "It failed in the merge queue and no agent is fixing it.", to, action: "See checks", at: Date.parse(pull.updatedAt), where });
263 }
264 }
265 for (const run of live) {
266 const minutes = stuckMinutes(run, now);
267 if (minutes == null) continue;
268 needs.push({
269 key: `run:${run.id}`,
270 kind: "stuck",
271 title: run.title ?? `${run.agent}'s run`,
272 detail: `${run.agent} has reported nothing for ${minutes} min${run.step ? `. Last: ${run.step}` : ""}.`,
273 to: `${base}/agents/runs/${run.id}`,
274 action: "Look",
275 at: Date.parse(run.updatedAt),
276 where: run.number != null ? `#${run.number}` : null,
277 });
278 }
279
280 // --- Activity and health -------------------------------------------------------
281 const eventList: G1tEvent[] = recent ?? [];
282 const items = eventList
283 .map((event) => eventItem(event, path))
284 .filter((item): item is ActivityItem => item != null);
285 const checkEvents = eventList.flatMap((event) =>
286 event.type === "checks.completed" ? [{ repo: "", number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [],
287 );
288 const memoryList = ok(memories)?.project ?? [];
289 const knows = [...memoryList.filter((m) => m.pinned), ...memoryList.filter((m) => !m.pinned).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))].slice(0, 5);
290 const openIssues = ok(issues);
291
292 // --- Getting to production ----------------------------------------------------
293 const wentLive = (status: string) => status === "ready" || status === "replaced" || status === "down";
294 const liveApps = ok(list)?.live ?? [];
295 const commitNow = ok(log)?.[0] ?? null;
296 const projectValue = ok(project);
297 const checklist = member
298 ? productionChecklist({
299 base,
300 hasCode: commitNow != null || projectValue?.source.kind === "mirror",
301 deploysEnabled: ok(settings)?.enabled ?? false,
302 productionDeployed:
303 liveApps.some((app) => app.kind === "production") ||
304 builds.some((build) => build.kind === "production" && wentLive(build.status)),
305 domains: ok(domains)?.domains.length ?? null,
306 previewOpened:
307 liveApps.some((app) => app.kind === "preview") || builds.some((build) => build.kind === "preview" && wentLive(build.status)),
308 instructions: commitNow == null ? false : root?.ok ? hasInstructions(root.value.entries.map((entry) => entry.name)) : null,
309 agentAssigned: agentWasAssigned({
310 runAgents: runList.map((run) => run.agent),
311 pullAgents: [...openPulls, ...(ok(closed) ?? [])].map((pull) => pull.agent),
312 issues: openIssues ?? [],
313 }),
314 })
315 : null;
316
317 const github = await githubP;
318 return {
319 member,
320 can,
321 github,
322 project: ok(project),
323 settings: ok(settings),
324 builds: builds.slice(0, 30),
325 live: ok(list)?.live ?? [],
326 commit: ok(log)?.[0] ?? null,
327 open: ok(counts) ?? { issues: openIssues?.length ?? 0, pulls: openPulls.length },
328 dependencies: ok(deps),
329 agentsLive: live.slice(0, 6),
330 runsLoaded: runs?.ok ?? false,
331 pullsLoaded: open?.ok ?? false,
332 columns,
333 needs: rankNeeds(needs),
334 landed: landed.slice(0, MAX_LANDED),
335 groups: groupActivity(items).slice(0, 30),
336 eventsLoaded: recent != null,
337 health: {
338 passRate: passRate(checkEvents.map((c) => c.passed)),
339 firstPass: firstPassRate(checkEvents),
340 checkRuns: checkEvents.length,
341 ages: openIssues ? ageBuckets(openIssues.map((issue) => issue.createdAt), now) : null,
342 },
343 knows: knows as Memory[],
344 memoryCount: memoryList.length,
345 memoriesLoaded: memories?.ok ?? false,
346 checklist,
347 branches,
348 };
349}
350
351export async function action({ request, params, context }: Route.ActionArgs) {
352 assertSameOrigin(request);
353 const user = requireUser(context, request);
354 const form = await request.formData().catch(() => null);
355 const intent = form?.get("intent");
356 // Syncing from GitHub is pushing; stopping it is an integration; deploying is compute.
357 const refused = await refusal(context, params, intent === "github-sync" ? "push" : intent === "github-stop" ? "manage_integrations" : "run");
358 if (refused) return { error: refused };
359 if (intent === "github-sync" || intent === "github-stop") {
360 const repo = await repos.get({ namespace: params.owner, name: params.repo }, user);
361 if (!repo.ok) return { error: repo.error.message };
362 const done =
363 intent === "github-sync" ? await githubApp.sync(user, repo.value.id) : await githubApp.unlinkRepo(user, repo.value.id);
364 return done.ok
365 ? { notice: intent === "github-sync" ? "Synced with GitHub." : "It is no longer kept in step with GitHub." }
366 : { error: done.error.message };
367 }
368 const started = await deployments.redeploy(user, { workspace: params.owner, slug: params.repo }, null);
369 return started.ok ? { notice: "Production is building." } : { error: started.error.message };
370}
371
372type Loaded = Awaited<ReturnType<typeof overviewData>>;
373type Card = Loaded["columns"]["working"][number];
374
375const STAGE_TONE: Record<PipelineStage, string> = {
376 working: "bg-merged",
377 checking: "bg-info",
378 reviewing: "bg-warn",
379 queue: "bg-accent-dim",
380 landed: "bg-accent",
381};
382
383function PipelineCard({ card, base, stage }: { card: Card; base: string; stage: PipelineStage }) {
384 return (
385 <li>
386 <Link
387 to={`${base}/pull/${card.number}`}
388 prefetch="intent"
389 className={`block rounded-lg border bg-bg/60 p-2.5 transition-colors hover:border-line-strong hover:bg-raised ${
390 card.needsYou ? "border-warn/50" : card.step ? "border-merged/30" : "border-line"
391 }`}
392 >
393 <span className="line-clamp-2 text-[0.8125rem] leading-snug font-medium">{card.title}</span>
394 <span className="mt-1.5 flex items-center gap-1.5 text-[0.6875rem] text-muted">
395 <Avatar name={card.agent} size={14} />
396 <span className="font-mono text-faint">#{card.number}</span>
397 <span className="grow" />
398 {card.needsYou ? (
399 <span className="inline-flex items-center gap-1 text-warn" title="Stopped: a person decides what happens next">
400 <Hand size={11} /> you
401 </span>
402 ) : stage === "landed" ? (
403 <TimeAgo at={card.at} />
404 ) : card.runStarted ? (
405 <span className="inline-flex items-center gap-1 text-merged">
406 <Loader2 size={11} className="animate-spin" />
407 <Elapsed from={card.runStarted} />
408 </span>
409 ) : (
410 <CheckBadge status={card.checkStatus} />
411 )}
412 </span>
413 {card.step && (
414 <span className="mt-1.5 block truncate font-mono text-[0.6875rem] text-fg/70" title={card.step}>
415 {card.runKind}: {card.step}
416 </span>
417 )}
418 </Link>
419 </li>
420 );
421}
422
423/** Open pull requests moving through Working → Checking → Reviewing → Queue → Landed. */
424function Pipeline({ columns, base }: { columns: Loaded["columns"]; base: string }) {
425 return (
426 <div className="relative -mx-4 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0">
427 {/* Each stage as tall as what is in it: an empty one stays short
428 instead of stretching to the busiest. */}
429 <ol className="grid min-w-176 grid-cols-5 items-start gap-2">
430 {PIPELINE.map(({ stage, label }, index) => {
431 const cards = columns[stage];
432 return (
433 <li key={stage} className="flex min-w-0 flex-col rounded-xl border border-line bg-surface p-2">
434 <p className="flex items-center gap-2 px-1 pt-0.5 pb-2 text-xs font-medium text-muted">
435 <span className={`size-1.5 rounded-full ${STAGE_TONE[stage]} ${cards.length && stage !== "landed" ? "animate-pulse" : ""}`} />
436 {label}
437 <span className="tabular-nums text-faint">{cards.length}</span>
438 {index < PIPELINE.length - 1 && <ArrowRight size={11} className="ml-auto text-line-strong" />}
439 </p>
440 {cards.length === 0 ? (
441 <p className="rounded-lg border border-dashed border-line/70 px-2 py-3 text-center text-[0.6875rem] text-faint">Empty</p>
442 ) : (
443 <ul className="space-y-1.5">
444 {cards.slice(0, 6).map((card) => (
445 <PipelineCard key={card.number} card={card} base={base} stage={stage} />
446 ))}
447 {cards.length > 6 && (
448 <li className="px-1 text-[0.6875rem] text-muted">
449 <Link to={`${base}/pulls`} className="hover:text-fg">
450 {cards.length - 6} more
451 </Link>
452 </li>
453 )}
454 </ul>
455 )}
456 </li>
457 );
458 })}
459 </ol>
460 </div>
461 );
462}
463
464function Stat({ label, value, to }: { label: string; value: ReactNode; to?: string }) {
465 const body = (
466 <>
467 <span className="block text-[0.6875rem] text-faint">{label}</span>
468 <span className="mt-0.5 block truncate text-[0.8125rem] font-medium">{value}</span>
469 </>
470 );
471 return to ? (
472 <Link to={to} className="min-w-0 px-5 py-3 transition-colors hover:bg-raised/50">
473 {body}
474 </Link>
475 ) : (
476 <div className="min-w-0 px-5 py-3">{body}</div>
477 );
478}
479
480export default function ProjectOverview({ loaderData, actionData, params }: Route.ComponentProps) {
481 return (
482 <Suspense fallback={<OverviewSkeleton />}>
483 <Await resolve={loaderData.overview} errorElement={<OverviewFailed />}>
484 {(overview) => <Overview loaderData={overview} actionData={actionData} params={params} />}
485 </Await>
486 </Suspense>
487 );
488}
489
490/** The overview's sections as they will sit, while they stream in. */
491function OverviewSkeleton() {
492 return (
493 <div className="space-y-8" aria-busy="true" aria-label="Loading the overview">
494 <section className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
495 <Skeleton className="h-3 w-24" />
496 <Skeleton className="mt-3 h-5 w-64 max-w-full" />
497 <Skeleton className="mt-3 h-3 w-48 max-w-full" />
498 </section>
499 <section className="rounded-2xl border border-line bg-surface p-2">
500 <div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
501 {Array.from({ length: 5 }, (_, index) => (
502 <Skeleton key={index} className="h-24 rounded-xl" />
503 ))}
504 </div>
505 </section>
506 <div className="grid gap-6 lg:grid-cols-2">
507 <section className="rounded-2xl border border-line bg-surface">
508 <SkeletonRows rows={4} />
509 </section>
510 <section className="rounded-2xl border border-line bg-surface">
511 <SkeletonRows rows={4} />
512 </section>
513 </div>
514 </div>
515 );
516}
517
518function OverviewFailed() {
519 return (
520 <p className="rounded-2xl border border-line bg-surface p-6 text-sm text-muted">
521 The overview could not be loaded just now. Reload the page to try again.
522 </p>
523 );
524}
525
526function Overview({
527 loaderData,
528 actionData,
529 params,
530}: {
531 loaderData: Loaded;
532 actionData: Route.ComponentProps["actionData"];
533 params: Route.ComponentProps["params"];
534}) {
535 const { member, project, settings, builds, live, commit, open, dependencies, agentsLive, columns, needs, landed, groups, health, knows, checklist, branches } =
536 loaderData;
537 const base = `/${params.owner}/${params.repo}`;
538 const production = live.find((app) => app.kind === "production") ?? null;
539 // The project's own domain, once it is active, is where production is visited.
540 const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null;
541 const previews = live.filter((app) => app.kind === "preview");
542 const latestProduction = builds.find((build) => build.kind === "production") ?? null;
543 const busy = useNavigation().state === "submitting";
544 const source = project?.source.kind === "hosted" ? project.source : null;
545 const moving = agentsLive.length > 0 || columns.working.length + columns.checking.length > 0;
546 useLiveRefresh(moving);
547 const ages = health.ages;
548 const oldest = ages ? Math.max(1, ...ages.map((a) => a.count)) : 1;
549
550 return (
551 <div className="space-y-8">
552 {loaderData.github && <GithubLinkStrip link={loaderData.github} />}
553 {/* What the project is, running, and where its code is. */}
554 <section className="overflow-hidden rounded-2xl border border-line bg-surface">
555 <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
556 {member && (production || settings?.enabled) && (
557 <ProductionShot
558 src={production ? `${base}/production.jpg?v=${shotVersion(production)}` : null}
559 href={production ? (productionUrl ?? production.url) : null}
560 label={host(production ? (productionUrl ?? production.url) : (settings?.productionUrl ?? ""))}
561 className="w-full shrink-0 sm:w-60 lg:w-72"
562 />
563 )}
564 <div className="flex min-w-0 grow flex-wrap items-start justify-between gap-4">
565 <div className="min-w-0">
566 <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
567 <Rocket size={13} className="text-accent" />
568 Production
569 </p>
570 {production ? (
571 <>
572 <a
573 href={productionUrl ?? production.url}
574 className="mt-2 flex items-center gap-1.5 truncate font-mono text-lg font-medium hover:text-accent"
575 >
576 {host(productionUrl ?? production.url)}
577 <ArrowUpRight size={16} className="shrink-0 text-faint" />
578 </a>
579 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
580 {latestProduction && <StatusDot status={latestProduction.status} label={latestProduction.status === "ready" ? "Live" : undefined} />}
581 <span className="inline-flex items-center gap-1 font-mono">
582 <GitCommitHorizontal size={13} className="text-faint" />
583 {production.commit.slice(0, 7)}
584 </span>
585 <span>
586 deployed <TimeAgo at={production.deployedAt} />
587 </span>
588 {settings?.primaryDomain && <span className="text-faint">also at {host(production.url)}</span>}
589 </p>
590 </>
591 ) : settings?.enabled ? (
592 <>
593 <p className="mt-2 font-mono text-lg text-muted">{host(settings.productionUrl)}</p>
594 <p className="mt-1.5 text-xs text-muted">
595 {latestProduction ? (
596 <StatusDot status={latestProduction.status} label={latestProduction.status === "failed" ? "The last build failed" : undefined} />
597 ) : (
598 "Not deployed yet. Push to the default branch, or deploy it now."
599 )}
600 </p>
601 </>
602 ) : (
603 <>
604 <p className="mt-2 text-lg font-medium">Deployments are off</p>
605 <p className="mt-1 max-w-lg text-sm text-muted">
606 Nothing builds or runs until you turn them on. Then {project?.name ?? params.repo} goes up on g1t.page:
607 production from the default branch, and a live preview for every pull request. It runs only while
608 someone visits.
609 </p>
610 </>
611 )}
612 </div>
613 {member && (
614 <div className="flex shrink-0 items-center gap-2">
615 {production && (
616 <ButtonLink to={productionUrl ?? production.url} variant="accent" reloadDocument>
617 Visit
618 <ArrowUpRight size={14} />
619 </ButtonLink>
620 )}
621 {settings?.enabled ? (
622 loaderData.can.run && <Form method="post">
623 <Button type="submit" variant="quiet" disabled={busy} title="Build production again from the default branch">
624 <RotateCw size={14} />
625 Redeploy
626 </Button>
627 </Form>
628 ) : loaderData.can.manage_integrations && (
629 <ButtonLink to={`${base}/settings/deployments`} variant="accent">
630 <Rocket size={14} />
631 Turn on deployments
632 </ButtonLink>
633 )}
634 </div>
635 )}
636 </div>
637 </div>
638 {actionData && (
639 <p className={`border-t border-line px-6 py-2.5 text-sm ${"error" in actionData ? "text-danger" : "text-accent"}`}>
640 {"error" in actionData ? actionData.error : actionData.notice}
641 </p>
642 )}
643 <div className="grid grid-cols-2 divide-line border-t border-line text-sm sm:grid-cols-4 sm:divide-x">
644 <Stat
645 label="Source"
646 to={`${base}/code`}
647 value={
648 source ? (
649 <span className="inline-flex items-center gap-1.5 font-mono">
650 {project?.private ? <Lock size={12} className="text-faint" /> : <Code2 size={12} className="text-faint" />}
651 {source.repo.name}
652 {source.rootDir ? `/${source.rootDir}` : ""}
653 </span>
654 ) : (
655 "—"
656 )
657 }
658 />
659 <Stat
660 label="Default branch"
661 to={`${base}/commits`}
662 value={
663 <span className="inline-flex items-center gap-1.5 font-mono">
664 <GitBranch size={12} className="text-faint" />
665 {source?.defaultBranch ?? "main"}
666 </span>
667 }
668 />
669 <Stat
670 label="Latest commit"
671 to={commit ? `${base}/commit/${commit.hash}` : undefined}
672 value={
673 commit ? (
674 <span title={commit.message.split("\n")[0]}>
675 <span className="font-mono">{commit.hash.slice(0, 7)}</span>{" "}
676 <span className="font-normal text-muted">
677 <TimeAgo at={commit.authoredAt} />
678 </span>
679 </span>
680 ) : (
681 "No commits yet"
682 )
683 }
684 />
685 <Stat
686 label={member && settings?.enabled ? "Last deploy" : "Open"}
687 to={member && settings?.enabled ? `${base}/deployments` : `${base}/pulls`}
688 value={
689 member && settings?.enabled ? (
690 latestProduction ? (
691 <span className="inline-flex items-center gap-2">
692 <StatusDot status={latestProduction.status} />
693 <span className="font-normal text-muted">
694 <TimeAgo at={latestProduction.createdAt} />
695 </span>
696 </span>
697 ) : (
698 "None yet"
699 )
700 ) : (
701 `${open.pulls} pull requests · ${open.issues} issues`
702 )
703 }
704 />
705 </div>
706 {commit && (
707 <p className="truncate border-t border-line px-5 py-2.5 text-xs text-muted sm:px-6">
708 <GitCommitHorizontal size={12} className="mr-1.5 inline text-faint" />
709 {commit.message.split("\n")[0]} <span className="text-faint">· {commit.author.name}</span>
710 </p>
711 )}
712 </section>
713
714 {checklist && <ProductionChecklist base={base} items={checklist} />}
715
716 <Panel
717 title="Right now"
718 icon={<span className={`block size-2 rounded-full ${agentsLive.length > 0 ? "animate-pulse bg-merged" : "bg-line-strong"}`} />}
719 count={agentsLive.length}
720 all={{ to: `${base}/agents`, label: "Agents" }}
721 >
722 {agentsLive.length > 0 && (
723 <ul className="mb-3 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
724 {agentsLive.map((run) => (
725 <li key={run.id} className="min-w-0 rounded-xl border border-merged/30 bg-surface px-3.5 py-3">
726 <div className="flex items-center gap-2 text-sm">
727 <Avatar name={run.agent} size={18} />
728 <span className="font-medium">{run.agent}</span>
729 <span className="text-xs text-muted">{RUN_KIND_LABEL[run.kind].toLowerCase()}</span>
730 <span className="grow" />
731 <span className="text-xs text-muted">
732 <Elapsed from={run.startedAt ?? run.createdAt} />
733 </span>
734 </div>
735 <Link
736 to={run.number != null ? `${base}/pull/${run.number}` : `${base}/agents/runs/${run.id}`}
737 className="mt-1 block truncate text-[0.8125rem] hover:text-accent"
738 >
739 {run.title ?? "A run"} {run.number != null && <span className="text-faint">#{run.number}</span>}
740 </Link>
741 {run.step && (
742 <p className="mt-1 truncate font-mono text-[0.6875rem] text-fg/70" title={run.step}>
743 <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />
744 {run.step}
745 </p>
746 )}
747 <p className="mt-1.5 flex items-center gap-3 text-[0.6875rem] text-faint">
748 {formatCost(run.costUsd) && <span>{formatCost(run.costUsd)} so far</span>}
749 <Link to={`${base}/agents/runs/${run.id}`} className="hover:text-fg">
750 {run.stepCount} steps
751 </Link>
752 {run.number != null && (
753 <Link to={`${base}/sessions/${run.number}`} className="hover:text-fg">
754 Session
755 </Link>
756 )}
757 </p>
758 </li>
759 ))}
760 </ul>
761 )}
762 {!loaderData.pullsLoaded ? (
763 <Unavailable what="Pull requests" />
764 ) : Object.values(columns).every((c) => c.length === 0) ? (
765 <Quiet
766 action={
767 member ? (
768 <Link to={`${base}/issues/new`} className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-xs font-medium text-bg hover:bg-white">
769 <Bot size={12} /> Open an issue for an agent
770 </Link>
771 ) : null
772 }
773 >
774 Nothing is moving. Open an issue and hand it to g1t, or push a branch and open a pull request.
775 </Quiet>
776 ) : (
777 <Pipeline columns={columns} base={base} />
778 )}
779 </Panel>
780
781 <div className="grid gap-x-8 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
782 <div className="min-w-0 space-y-8">
783 {needs.length > 0 && (
784 <Panel title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}>
785 <NeedsList needs={needs} limit={5} />
786 </Panel>
787 )}
788
789 <Suspense
790 fallback={
791 <Panel title="Active branches" icon={<GitBranch size={14} />}>
792 <div aria-busy="true">
793 <SkeletonRows rows={3} rowClassName="h-12" />
794 </div>
795 </Panel>
796 }
797 >
798 <Await resolve={branches}>
799 {(branches) => (
800 <Panel title="Active branches" icon={<GitBranch size={14} />} count={branches?.total || null}>
801 {!branches ? (
802 <Unavailable what="Branches" />
803 ) : branches.shown.length === 0 ? (
804 <Quiet>
805 Only {branches.main} so far. Branches pushed here show with how far each has moved from {branches.main}, and
806 its pull request and preview.
807 </Quiet>
808 ) : (
809 <>
810 <ActiveBranches branches={branches.shown} base={base} main={branches.main} />
811 {branches.total > branches.shown.length && (
812 <p className="mt-2 px-1 text-xs text-faint">
813 The {branches.shown.length} most recently changed of {branches.total} branches.
814 </p>
815 )}
816 </>
817 )}
818 </Panel>
819 )}
820 </Await>
821 </Suspense>
822
823 <Panel title="Recent changes" icon={<GitMerge size={14} />} all={{ to: `${base}/pulls?state=closed`, label: "All landed" }}>
824 {landed.length === 0 ? (
825 <Quiet>Nothing has landed yet. Merged pull requests show here with who made them and why.</Quiet>
826 ) : (
827 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
828 {landed.map((pull) => {
829 const byAgent = pull.runtime === "hosted" || madeByG1t(pull);
830 return (
831 <li key={pull.id} className="flex items-start gap-3 px-4 py-3">
832 <GitMerge size={15} className="mt-0.5 shrink-0 text-merged" />
833 <span className="min-w-0 grow">
834 <Link to={`${base}/pull/${pull.number}`} prefetch="intent" className="block truncate text-sm font-medium hover:text-accent">
835 {pull.title}
836 </Link>
837 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
838 <span className="font-mono text-faint">#{pull.number}</span>
839 <span>·</span>
840 <span className="inline-flex items-center gap-1">
841 <Avatar name={pull.agent} size={13} />
842 {pull.requestedBy
843 ? `made by ${pull.author.username} for ${pull.requestedBy.username}`
844 : byAgent
845 ? `made by ${pull.agent}`
846 : `by ${pull.author.username}`}
847 </span>
848 {pull.issue != null && (
849 <>
850 <span>· for</span>
851 <Link to={`${base}/issues/${pull.issue}`} className="hover:text-fg">
852 #{pull.issue}
853 </Link>
854 </>
855 )}
856 {pull.mergedBy && <span>· landed by {pull.mergedBy}</span>}
857 <span>
858 · <TimeAgo at={pull.mergedAt ?? pull.updatedAt} />
859 </span>
860 </span>
861 </span>
862 <span className="hidden shrink-0 text-xs text-faint sm:block">
863 <ChangeSize files={pull.files} />
864 </span>
865 {byAgent && (
866 <Link to={`${base}/sessions/${pull.number}`} className="shrink-0 text-xs text-muted hover:text-fg">
867 Session
868 </Link>
869 )}
870 </li>
871 );
872 })}
873 </ul>
874 )}
875 </Panel>
876
877 <Panel title="Activity" icon={<ActivityIcon size={14} />}>
878 {loaderData.eventsLoaded ? (
879 <ActivityFeed groups={groups} showRepo={false} limit={8} empty="Nothing has happened here yet." />
880 ) : (
881 <Unavailable what="Activity" />
882 )}
883 </Panel>
884
885 {previews.length > 0 && (
886 <Panel title="Previews" icon={<GitBranch size={14} />} count={previews.length} all={{ to: `${base}/deployments`, label: "Deployments" }}>
887 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
888 {previews.map((app) => (
889 <li key={app.url} className="flex items-center gap-3 px-4 py-2.5 text-sm">
890 <span className="min-w-0 grow">
891 <a href={app.url} className="block truncate font-mono text-[0.8125rem] hover:text-accent">
892 {host(app.url)}
893 </a>
894 <span className="text-xs text-muted">
895 {app.branch}
896 {app.number != null && (
897 <>
898 {" · "}
899 <Link to={`${base}/pull/${app.number}`} className="hover:underline">
900 #{app.number}
901 </Link>
902 </>
903 )}
904 </span>
905 </span>
906 <span className="shrink-0 text-xs text-faint">
907 <TimeAgo at={app.deployedAt} />
908 </span>
909 </li>
910 ))}
911 </ul>
912 </Panel>
913 )}
914 </div>
915
916 <aside className="space-y-4 text-sm">
917 {member && (
918 <section className="rounded-xl border border-line bg-surface p-5">
919 <div className="flex items-center justify-between">
920 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
921 <Brain size={14} className="text-merged" />
922 What agents know here
923 </h2>
924 <Link to={`${base}/memory`} className="text-xs text-muted hover:text-fg">
925 Memory
926 </Link>
927 </div>
928 {!loaderData.memoriesLoaded ? (
929 <p className="mt-3 text-xs text-muted">Memory could not be loaded just now.</p>
930 ) : knows.length === 0 ? (
931 <p className="mt-3 text-xs leading-5 text-muted">
932 Nothing yet. Agents note conventions, decisions and traps as they work, and every agent starting here reads them.
933 </p>
934 ) : (
935 <ul className="mt-3 space-y-2.5">
936 {knows.map((memory) => (
937 <li key={memory.id} className="text-xs leading-5">
938 <p className="line-clamp-3 text-fg-soft">
939 {memory.pinned && <Pin size={11} className="mr-1 inline text-accent" />}
940 {memory.text}
941 </p>
942 <p className="text-faint">
943 {memory.kind} · {memory.createdBy} · <TimeAgo at={memory.createdAt} />
944 </p>
945 </li>
946 ))}
947 {loaderData.memoryCount > knows.length && (
948 <li>
949 <Link to={`${base}/memory`} className="text-xs text-muted hover:text-fg">
950 {loaderData.memoryCount - knows.length} more
951 </Link>
952 </li>
953 )}
954 </ul>
955 )}
956 </section>
957 )}
958
959 <section className="rounded-xl border border-line bg-surface p-5">
960 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
961 <HeartPulse size={14} className="text-faint" />
962 Health
963 </h2>
964 <div className="mt-4 space-y-4 text-xs">
965 <div>
966 <div className="flex items-baseline justify-between">
967 <span className="text-muted">Checks passing</span>
968 <span className="font-medium tabular-nums">{percent(health.passRate)}</span>
969 </div>
970 <div className="mt-1.5">
971 <Meter value={health.passRate} tone={health.passRate != null && health.passRate < 0.7 ? "bg-warn" : "bg-accent"} />
972 </div>
973 <p className="mt-1 text-faint">
974 {health.checkRuns
975 ? `${health.checkRuns} recent runs · ${percent(health.firstPass.rate)} pass on the first try`
976 : "No checks have run recently."}
977 </p>
978 </div>
979 {member && (
980 <div>
981 <div className="flex items-baseline justify-between">
982 <span className="text-muted">Deploys</span>
983 <Link to={`${base}/deployments`} className="text-faint hover:text-fg">
984 {builds.length ? `last ${Math.min(builds.length, 20)}` : "none yet"}
985 </Link>
986 </div>
987 <div className="mt-1.5">
988 <DeployStrip builds={builds} base={base} />
989 </div>
990 </div>
991 )}
992 <div>
993 <div className="flex items-baseline justify-between">
994 <span className="text-muted">Open issues by age</span>
995 <Link to={`${base}/issues`} className="font-medium tabular-nums hover:text-accent">
996 {open.issues}
997 </Link>
998 </div>
999 {ages ? (
1000 <ul className="mt-2 space-y-1.5">
1001 {ages.map((bucket) => (
1002 <li key={bucket.label} className="grid grid-cols-[6.5rem_1fr_1.5rem] items-center gap-2">
1003 <span className="text-faint">{bucket.label}</span>
1004 <span className="block h-1.5 overflow-hidden rounded-full bg-line">
1005 <span
1006 className={`block h-full rounded-full ${bucket.label === "Older" ? "bg-warn" : "bg-fg-soft/60"}`}
1007 style={{ width: `${(bucket.count / oldest) * 100}%` }}
1008 />
1009 </span>
1010 <span className="text-right tabular-nums text-muted">{bucket.count}</span>
1011 </li>
1012 ))}
1013 </ul>
1014 ) : (
1015 <p className="mt-1 text-faint">Issues could not be loaded just now.</p>
1016 )}
1017 </div>
1018 </div>
1019 </section>
1020
1021 <section className="rounded-xl border border-line bg-surface p-5">
1022 <div className="flex items-center justify-between">
1023 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
1024 <Network size={14} className="text-faint" />
1025 Dependencies
1026 </h2>
1027 {member && (
1028 <Link to={`${base}/settings/dependencies`} className="text-xs text-muted hover:text-fg">
1029 Manage
1030 </Link>
1031 )}
1032 </div>
1033 {!dependencies ? (
1034 <p className="mt-3 text-xs text-muted">Dependencies could not be loaded just now.</p>
1035 ) : dependencies.dependsOn.length === 0 && dependencies.usedBy.length === 0 ? (
1036 <p className="mt-3 text-xs leading-5 text-muted">
1037 Uses no other project, and none uses it. Declare one, and builds get its address and agents know what depends on
1038 what.
1039 </p>
1040 ) : (
1041 <div className="mt-3 space-y-3 text-xs">
1042 {dependencies.dependsOn.length > 0 && (
1043 <div>
1044 <p className="flex items-center gap-1 text-muted">
1045 <ArrowUpRight size={12} /> Uses
1046 </p>
1047 <ul className="mt-1.5 space-y-1">
1048 {dependencies.dependsOn.map((d) => (
1049 <li key={d.slug} className="flex items-center justify-between gap-2">
1050 <Link to={`/${params.owner}/${d.slug}`} className="font-medium hover:underline">
1051 {d.name}
1052 </Link>
1053 {d.as && <code className="font-mono text-faint">{d.as}</code>}
1054 </li>
1055 ))}
1056 </ul>
1057 </div>
1058 )}
1059 {dependencies.usedBy.length > 0 && (
1060 <div>
1061 <p className="flex items-center gap-1 text-muted">
1062 <ArrowDownLeft size={12} /> Used by
1063 </p>
1064 <ul className="mt-1.5 space-y-1">
1065 {dependencies.usedBy.map((d) => (
1066 <li key={d.slug}>
1067 <Link to={`/${params.owner}/${d.slug}`} className="font-medium hover:underline">
1068 {d.name}
1069 </Link>
1070 </li>
1071 ))}
1072 </ul>
1073 </div>
1074 )}
1075 </div>
1076 )}
1077 </section>
1078
1079 {source && (
1080 <section className="rounded-xl border border-line bg-surface p-5">
1081 <h2 className="text-sm font-semibold">Clone</h2>
1082 <div className="mt-3">
1083 <CopyLine text={`git clone https://g1t.sh/${source.repo.namespace}/${source.repo.name}.git`} />
1084 </div>
1085 </section>
1086 )}
1087 </aside>
1088 </div>
1089 </div>
1090 );
1091}