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

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