flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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