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

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