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

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