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

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