Skip to content

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

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