Skip to content

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

1,450 lines66,463 bytesCodeBlame
1import {
2 Activity as ActivityIcon,
3 ArrowDownLeft,
4 ArrowRight,
5 ArrowUpRight,
6 BookOpen,
7 Bot,
8 Brain,
9 Globe,
10 Tag,
11 Code2,
12 GitBranch,
13 GitCommitHorizontal,
14 GitMerge,
15 Hand,
16 HeartPulse,
17 Loader2,
18 Lock,
19 Network,
20 Package as PackageGlyph,
21 Pin,
22 Rocket,
23 RotateCw,
24} from "lucide-react";
25import { type ReactNode, Suspense } from "react";
26import { Await, Form, Link } from "react-router";
27
28import {
29 type AgentRun,
30 type G1tEvent,
31 type Memory,
32 type PackageSummary,
33 type Project,
34 type Pull,
35 RUN_KIND_LABEL,
36 isActiveRun,
37 workOwner,
38} from "@g1t/contracts";
39
40import type { Route } from "./+types/overview";
41import { InlineMarkdown } from "../../components/inline-markdown";
42import { host, StatusDot } from "../../components/deploy";
43import { CheckBadge } from "../../components/checks";
44import { Elapsed, formatCost, useLiveRefresh } from "../../components/agents";
45import { ActivityFeed, DeployStrip, Meter, NeedsList, Panel, Quiet, Unavailable, percent } from "../../components/mission";
46import { type ActiveBranch, ActiveBranches } from "../../components/branches";
47import { ProductionChecklist } from "../../components/checklist";
48import { PackageIcon } from "../../components/package-icon";
49import { Hint } from "../../components/ui/hint";
50import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton";
51import { ProductionShot } from "../../components/production-shot";
52import { GithubLinkStrip } from "../../components/github";
53import { distinctFacts } from "../../lib/memory-facts";
54import { githubApp } from "../../lib/github.server";
55import { Avatar, ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui";
56import { ChangeSize } from "../../components/work";
57import {
58 type ActivityItem,
59 type Need,
60 type PipelineStage,
61 PIPELINE,
62 TIME,
63 ageBuckets,
64 eventItem,
65 firstPassRate,
66 groupActivity,
67 passRate,
68 pipelineStage,
69 queuedNumbers,
70 rankNeeds,
71 stuckMinutes,
72} from "../../lib/mission";
73import { agentWasAssigned, checklistPlan, hasInstructions, productionChecklist, releaseChecklist, startChecklist } from "../../lib/checklist";
74import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages";
75import {
76 PUBLISH_GUIDES,
77 bare,
78 hasRelease,
79 kindLabel,
80 linksToShow,
81 latestTag,
82 libraryPackages,
83 packageName,
84 packagePath,
85 projectChanges,
86 publishGuide,
87} from "../../lib/project-kind";
88import { AboutEditor, KindMenu, LinkList } from "../../components/project-about";
89import { DocsHead, ElsewhereHead, type ExternalDeployment, OtherHead, WhereItRuns } from "../../components/project-head";
90import { actions, agents, deployments, events as eventLog, packages, projects, repos, work } from "../../lib/services.server";
91import { madeByG1t } from "../../lib/opened-by";
92import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
93import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server";
94import { shotVersion } from "./production-screenshot";
95import { cloneUrl, useAddresses } from "../../lib/addresses";
96import { readBranches } from "../../lib/branches.server";
97
98const MAX_LANDED = 6;
99/** Branches read for the Active branches list, and shown. */
100const BRANCHES_READ = 10;
101const BRANCHES_SHOWN = 5;
102/** A library's packages shown on its overview; the rest are a link away. */
103const PACKAGES_SHOWN = 3;
104/** The default branch's latest commits listed. */
105const COMMITS_SHOWN = 5;
106/** How long Active branches may take before the section links to Branches instead. */
107const BRANCHES_WAIT_MS = 3_500;
108
109/**
110 * The overview streams: the layout's header and tabs (one repository
111 * lookup, one project) go out at once with a skeleton below them, and
112 * everything here, seventeen calls across six services, follows in the
113 * same response as it settles. Crawlers wait for all of it
114 * (entry.server.tsx). Before, the first byte waited on the slowest of them.
115 */
116export function loader({ params, context }: Route.LoaderArgs) {
117 return { overview: overviewData({ params, context }) };
118}
119
120async function overviewData({ params, context }: Pick<Route.LoaderArgs, "params" | "context">) {
121 const viewer = getViewer(context);
122 const path = { namespace: params.owner, name: params.repo };
123 const ref = { workspace: params.owner, slug: params.repo };
124 const now = Date.now();
125 // A section whose service fails shows its own empty state; the page stays up.
126 const soft = <T,>(promise: Promise<T> | null): Promise<T | null> =>
127 promise ? promise.catch((error) => (console.warn("overview:", error), null)) : Promise.resolve(null);
128 // People with a role of their own here see its running parts: deployments,
129 // memory, setup; everyone who can read it sees the rest. Not awaited: only
130 // those sections wait on the repository lookup, which the layout makes in
131 // this request too.
132 const accessP = accessTo(context, params);
133 const memberP = accessP.then((access) => access.insider);
134 const forMembers = <T,>(start: () => Promise<T>): Promise<T | null> =>
135 memberP.then((member) => (member ? soft(start()) : null));
136 const repoP = soft(repoFor(context, params));
137 const projectP = soft(projects.get(params.owner, params.repo, viewer));
138 // A library or a tool shows its packages where an app shows production,
139 // and every project g1t does not deploy counts a workflow in its checklist.
140 const planP = projectP.then((project) => (project?.ok ? checklistPlan(project.value).plan : null));
141 const libraryRepoP = Promise.all([planP, repoP]).then(([plan, repo]) => (plan === "release" && repo?.ok ? repo.value : null));
142 const packagesP = libraryRepoP.then((repo) => (repo ? soft(packages.list(params.owner, viewer, { repoId: repo.id })) : null));
143 const workflowsP = planP.then((plan) => (plan && plan !== "production" ? forMembers(() => actions.workflows(path, viewer)) : null));
144 const eventsP = repoP.then((repo) => (repo?.ok ? soft(eventLog.list({ repoId: repo.value.id, limit: 150 })) : null));
145 const minePullsP = repoP.then((repo) =>
146 repo?.ok && viewer
147 ? soft(work.listActivePulls(viewer)).then((list) => (list ?? []).filter((item) => item.pull.repoId === repo.value.id))
148 : null,
149 );
150 const openP = soft(work.listPulls(path, viewer, "open"));
151 // Where its code came from on GitHub, for members.
152 const githubP = repoP.then((repo) => (repo?.ok ? forMembers(() => githubApp.link(repo.value.id)) : null));
153 const listP = forMembers(() => deployments.list(ref, viewer));
154 // Active branches: the newest few besides the default, with how far each
155 // has moved. Branches with an open pull request are read first.
156 const branchesP = Promise.all([repoP, soft(repos.branches(path, viewer)), openP, listP]).then(
157 async ([repo, branchList, pulls, deploys]): Promise<{ main: string; total: number; shown: ActiveBranch[] } | null> => {
158 if (!repo?.ok || !branchList?.ok) return null;
159 const main = repo.value.defaultBranch;
160 if (branchList.value.every((branch) => branch.name === main)) return { main, total: 0, shown: [] };
161 const read = await readBranches(
162 path,
163 viewer,
164 {
165 defaultBranch: main,
166 branches: branchList.value,
167 pulls: pulls?.ok ? pulls.value : [],
168 previews: deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [],
169 },
170 BRANCHES_READ,
171 );
172 return { main, total: read.total, shown: read.shown.slice(0, BRANCHES_SHOWN) };
173 },
174 );
175 // Active branches read several logs each: streamed, so the rest shows
176 // first. Bounded well inside the response's stream timeout
177 // (entry.server.tsx): a promise still pending when the stream ends never
178 // settles in the browser, and its skeleton would stay.
179 const branches = Promise.race([
180 branchesP.catch(() => null),
181 new Promise<"slow">((resolve) => setTimeout(() => resolve("slow"), BRANCHES_WAIT_MS)),
182 ]);
183 const [{ insider: member, can }, project, settings, list, open, closed, log, counts, deps, runs, queue, issues, memories, recent, mine, domains, root, packageList, workflows, tagList] = await Promise.all([
184 accessP,
185 projectP,
186 forMembers(() => deployments.settings(ref, viewer)),
187 listP,
188 openP,
189 soft(work.listPulls(path, viewer, "closed")),
190 soft(repos.log(path, viewer, null, COMMITS_SHOWN)),
191 soft(countsFor(context, params)),
192 soft(projects.dependencies(params.owner, params.repo, viewer)),
193 soft(agents.listRuns(viewer, { repo: path, limit: 60 })),
194 soft(work.queue(path, viewer)),
195 soft(work.listIssues(path, viewer, { state: "open" })),
196 forMembers(() => agents.listMemories(viewer, params.owner, path)),
197 eventsP,
198 minePullsP,
199 // For the checklist, which only members see.
200 forMembers(() => deployments.domains(ref, viewer)),
201 forMembers(() => repos.tree(path, viewer, null, "")),
202 packagesP,
203 workflowsP,
204 // The latest release, for About.
205 soft(repos.tags(path, viewer)),
206 ]);
207 const ok = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
208
209 const openPulls = ok(open) ?? [];
210 const runList = ok(runs) ?? [];
211 const live = runList.filter((run) => isActiveRun(run.status));
212 const builds = ok(list)?.deployments ?? [];
213 const landed = (ok(closed) ?? [])
214 .filter((pull) => pull.status === "merged")
215 .sort((a, b) => Date.parse(b.mergedAt ?? b.updatedAt) - Date.parse(a.mergedAt ?? a.updatedAt));
216 const queued = queuedNumbers(ok(queue)?.active ?? []);
217 const runOn = new Map<number, AgentRun>();
218 for (const run of live) if (run.number != null && !runOn.has(run.number)) runOn.set(run.number, run);
219 const lifecycleOf = new Map((mine ?? []).map((item) => [item.pull.number, item.lifecycle]));
220
221 // --- The pipeline -----------------------------------------------------------
222 type Card = {
223 number: number;
224 title: string;
225 agent: string;
226 status: Pull["status"];
227 checkStatus: Pull["checkStatus"];
228 step: string | null;
229 runKind: string | null;
230 runStarted: string | null;
231 needsYou: boolean;
232 at: string;
233 };
234 const columns: Record<PipelineStage, Card[]> = { working: [], checking: [], reviewing: [], queue: [], landed: [] };
235 for (const pull of openPulls.slice(0, 40)) {
236 const run = runOn.get(pull.number);
237 columns[pipelineStage(pull, run, queued, lifecycleOf.get(pull.number))].push({
238 number: pull.number,
239 title: pull.title,
240 agent: pull.agent,
241 status: pull.status,
242 checkStatus: pull.checkStatus,
243 step: run?.step ?? null,
244 runKind: run ? RUN_KIND_LABEL[run.kind] : null,
245 runStarted: run ? (run.startedAt ?? run.createdAt) : null,
246 needsYou: lifecycleOf.get(pull.number)?.stage === "needs_you",
247 at: pull.updatedAt,
248 });
249 }
250 for (const pull of landed.filter((p) => now - Date.parse(p.mergedAt ?? p.updatedAt) < 3 * TIME.DAY).slice(0, 5)) {
251 columns.landed.push({
252 number: pull.number,
253 title: pull.title,
254 agent: pull.agent,
255 status: pull.status,
256 checkStatus: pull.checkStatus,
257 step: null,
258 runKind: null,
259 runStarted: null,
260 needsYou: false,
261 at: pull.mergedAt ?? pull.updatedAt,
262 });
263 }
264
265 // --- Needs you ---------------------------------------------------------------
266 const base = `/${params.owner}/${params.repo}`;
267 const needs: Need[] = [];
268 const latestProduction = builds.find((build) => build.kind === "production") ?? null;
269 if (latestProduction?.status === "failed") {
270 needs.push({
271 key: "deploy",
272 kind: "deploy",
273 title: "The production build failed",
274 detail: latestProduction.error ?? "Production still serves the build before it.",
275 to: `${base}/deployments/${latestProduction.id}`,
276 action: "See the build",
277 at: Date.parse(latestProduction.finishedAt ?? latestProduction.createdAt),
278 where: latestProduction.commit.slice(0, 7),
279 });
280 }
281 const me = viewer?.username.toLowerCase();
282 for (const pull of openPulls) {
283 const to = `${base}/pull/${pull.number}`;
284 const where = `#${pull.number}`;
285 const lifecycle = lifecycleOf.get(pull.number);
286 if (lifecycle?.stage === "needs_you") {
287 const conflict = /conflict/i.test(lifecycle.detail);
288 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 });
289 } else if (lifecycle?.stage === "ready") {
290 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 });
291 }
292 // Never your own, nor one g1t made for you; whoever it is for asked.
293 const owner = workOwner(pull).username;
294 if (me && pull.status === "open" && owner.toLowerCase() !== me && pull.reviewers.some((name) => name.toLowerCase() === me)) {
295 needs.push({ key: `review:${pull.number}`, kind: "review", title: pull.title, detail: `${owner} asked for your review.`, to, action: "Review", at: Date.parse(pull.updatedAt), where });
296 }
297 if (member && pull.status === "open" && pull.checkStatus === "failed" && !runOn.has(pull.number) && !lifecycle) {
298 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 });
299 }
300 }
301 for (const run of live) {
302 const minutes = stuckMinutes(run, now);
303 if (minutes == null) continue;
304 needs.push({
305 key: `run:${run.id}`,
306 kind: "stuck",
307 title: run.title ?? `${run.agent}'s run`,
308 detail: `${run.agent} has reported nothing for ${minutes} min${run.step ? `. Last: ${run.step}` : ""}.`,
309 to: `${base}/agents/runs/${run.id}`,
310 action: "Look",
311 at: Date.parse(run.updatedAt),
312 where: run.number != null ? `#${run.number}` : null,
313 });
314 }
315
316 // --- Activity and health -------------------------------------------------------
317 const eventList: G1tEvent[] = recent ?? [];
318 const items = eventList
319 .map((event) => eventItem(event, path))
320 .filter((item): item is ActivityItem => item != null);
321 const checkEvents = eventList.flatMap((event) =>
322 event.type === "checks.completed" ? [{ repo: "", number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [],
323 );
324 const memoryList = ok(memories)?.project ?? [];
325 // Pinned first, then the newest; a fact remembered twice, once.
326 const knows = distinctFacts([...memoryList.filter((m) => m.pinned), ...memoryList.filter((m) => !m.pinned).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))]).slice(0, 5);
327 const openIssues = ok(issues);
328
329 // --- Getting to production ----------------------------------------------------
330 const wentLive = (status: string) => status === "ready" || status === "replaced" || status === "down";
331 const liveApps = ok(list)?.live ?? [];
332 const commitNow = ok(log)?.[0] ?? null;
333 const projectValue = ok(project);
334 const plan = projectValue ? checklistPlan(projectValue) : { plan: "production" as const, title: "Get to production" };
335 const isLibrary = plan.plan === "release";
336 const published = libraryPackages(ok(packageList) ?? []);
337 const hasWorkflow = workflows?.ok ? workflows.value.length > 0 : null;
338 const firstSteps = {
339 base,
340 hasCode: commitNow != null || projectValue?.source.kind === "mirror",
341 instructions: commitNow == null ? false : root?.ok ? hasInstructions(root.value.entries.map((entry) => entry.name)) : null,
342 agentAssigned: agentWasAssigned({
343 runAgents: runList.map((run) => run.agent),
344 pullAgents: [...openPulls, ...(ok(closed) ?? [])].map((pull) => pull.agent),
345 issues: openIssues ?? [],
346 }),
347 };
348 const links = projectValue?.links;
349 const checklist = !member
350 ? null
351 : plan.plan !== "production" && plan.plan !== "release"
352 ? startChecklist(plan.plan, {
353 ...firstSteps,
354 hasWorkflow,
355 productionUrl: projectValue?.productionUrl ?? null,
356 hasLinks: !!links && (links.homepage != null || links.docs != null || links.custom.length > 0),
357 hasDocsLink: !!links && (links.docs != null || links.homepage != null),
358 })
359 : isLibrary
360 ? releaseChecklist({
361 ...firstSteps,
362 hasWorkflow,
363 released: hasRelease(published),
364 releaseTo: published[0]
365 ? packagePath(published[0])
366 : (publishGuide(projectValue?.ecosystem ?? null)?.guide ?? "https://docs.g1t.sh/guides/packages/"),
367 })
368 : productionChecklist({
369 ...firstSteps,
370 deploysEnabled: ok(settings)?.enabled ?? false,
371 productionDeployed:
372 liveApps.some((app) => app.kind === "production") ||
373 builds.some((build) => build.kind === "production" && wentLive(build.status)),
374 domains: ok(domains)?.domains.length ?? null,
375 previewOpened:
376 liveApps.some((app) => app.kind === "preview") || builds.some((build) => build.kind === "preview" && wentLive(build.status)),
377 });
378
379 const github = await githubP;
380 return {
381 member,
382 can,
383 github,
384 project: ok(project),
385 settings: ok(settings),
386 builds: builds.slice(0, 30),
387 live: ok(list)?.live ?? [],
388 commit: ok(log)?.[0] ?? null,
389 commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author.name, at: one.authoredAt })),
390 release: latestTag(ok(tagList) ?? []),
391 checklistTitle: plan.title,
392 // Production as reported from outside g1t, for an app deployed elsewhere:
393 // filled in once deployments can be reported through the API.
394 deployment: null as ExternalDeployment | null,
395 // About's languages and contributors, filled in by the files' About work.
396 languages: null as { name: string; share: number }[] | null,
397 contributors: null as { username: string }[] | null,
398 open: ok(counts) ?? { issues: openIssues?.length ?? 0, pulls: openPulls.length },
399 dependencies: ok(deps),
400 agentsLive: live.slice(0, 6),
401 runsLoaded: runs?.ok ?? false,
402 pullsLoaded: open?.ok ?? false,
403 columns,
404 needs: rankNeeds(needs),
405 landed: landed.slice(0, MAX_LANDED),
406 groups: groupActivity(items).slice(0, 30),
407 eventsLoaded: recent != null,
408 health: {
409 passRate: passRate(checkEvents.map((c) => c.passed)),
410 firstPass: firstPassRate(checkEvents),
411 checkRuns: checkEvents.length,
412 ages: openIssues ? ageBuckets(openIssues.map((issue) => issue.createdAt), now) : null,
413 },
414 knows: knows as Memory[],
415 memoryCount: memoryList.length,
416 memoriesLoaded: memories?.ok ?? false,
417 checklist,
418 library: isLibrary
419 ? { packages: published.slice(0, PACKAGES_SHOWN), total: published.length, loaded: packageList?.ok ?? false }
420 : null,
421 branches,
422 };
423}
424
425export async function action({ request, params, context }: Route.ActionArgs) {
426 assertSameOrigin(request);
427 const user = requireUser(context, request);
428 const form = await request.formData().catch(() => null);
429 const intent = form?.get("intent");
430 // What it is, where it runs, its description and links: its settings.
431 if (form && (intent === "kind" || intent === "about")) {
432 const refused = await refusal(context, params, "manage_settings");
433 if (refused) return { error: refused };
434 const saved = await projects.update(user, params.owner, params.repo, projectChanges(form));
435 return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
436 }
437 // Syncing from GitHub is pushing; stopping it is an integration; deploying is compute.
438 const refused = await refusal(context, params, intent === "github-sync" ? "push" : intent === "github-stop" ? "manage_integrations" : "run");
439 if (refused) return { error: refused };
440 if (intent === "github-sync" || intent === "github-stop") {
441 const repo = await repos.get({ namespace: params.owner, name: params.repo }, user);
442 if (!repo.ok) return { error: repo.error.message };
443 const done =
444 intent === "github-sync" ? await githubApp.sync(user, repo.value.id) : await githubApp.unlinkRepo(user, repo.value.id);
445 return done.ok
446 ? { notice: intent === "github-sync" ? "Synced with GitHub." : "It is no longer kept in step with GitHub." }
447 : { error: done.error.message };
448 }
449 const started = await deployments.redeploy(user, { workspace: params.owner, slug: params.repo }, null);
450 return started.ok ? { notice: "Production is building." } : { error: started.error.message };
451}
452
453type Loaded = Awaited<ReturnType<typeof overviewData>>;
454type Card = Loaded["columns"]["working"][number];
455
456const STAGE_TONE: Record<PipelineStage, string> = {
457 working: "bg-accent",
458 checking: "bg-info",
459 reviewing: "bg-warn",
460 queue: "bg-success-dim",
461 landed: "bg-success",
462};
463
464function PipelineCard({ card, base, stage }: { card: Card; base: string; stage: PipelineStage }) {
465 return (
466 <li>
467 <Link
468 to={`${base}/pull/${card.number}`}
469 prefetch="intent"
470 className={`block rounded-lg border bg-bg/60 p-2.5 transition-colors hover:border-line-strong hover:bg-raised ${
471 card.needsYou ? "border-warn/50" : card.step ? "border-accent/30" : "border-line"
472 }`}
473 >
474 <span className="line-clamp-2 text-[0.8125rem] leading-snug font-medium">{card.title}</span>
475 <span className="mt-1.5 flex items-center gap-1.5 text-[0.6875rem] text-muted">
476 <Avatar name={card.agent} size={14} />
477 <span className="font-mono text-faint">#{card.number}</span>
478 <span className="grow" />
479 {card.needsYou ? (
480 <Hint label="Stopped: a person decides what happens next">
481 <span className="inline-flex items-center gap-1 text-warn">
482 <Hand size={11} /> you
483 <span className="sr-only">: stopped, a person decides what happens next</span>
484 </span>
485 </Hint>
486 ) : stage === "landed" ? (
487 <TimeAgo at={card.at} />
488 ) : card.runStarted ? (
489 <span className="inline-flex items-center gap-1 text-accent">
490 <Loader2 size={11} className="animate-spin" />
491 <Elapsed from={card.runStarted} />
492 </span>
493 ) : (
494 <CheckBadge status={card.checkStatus} />
495 )}
496 </span>
497 {card.step && (
498 <Hint label={card.step}>
499 <span className="mt-1.5 block truncate font-mono text-[0.6875rem] text-fg/70">
500 {card.runKind}: {card.step}
501 </span>
502 </Hint>
503 )}
504 </Link>
505 </li>
506 );
507}
508
509/** Open pull requests moving through Working → Checking → Reviewing → Queue → Landed. */
510function Pipeline({ columns, base }: { columns: Loaded["columns"]; base: string }) {
511 return (
512 <div className="relative -mx-4 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0">
513 {/* Each stage as tall as what is in it: an empty one stays short
514 instead of stretching to the busiest. */}
515 <ol className="grid min-w-176 grid-cols-5 items-start gap-2">
516 {PIPELINE.map(({ stage, label }, index) => {
517 const cards = columns[stage];
518 return (
519 <li key={stage} className="flex min-w-0 flex-col rounded-xl border border-line bg-surface p-2">
520 <p className="flex items-center gap-2 px-1 pt-0.5 pb-2 text-xs font-medium text-muted">
521 <span className={`size-1.5 rounded-full ${STAGE_TONE[stage]} ${cards.length && stage !== "landed" ? "animate-pulse" : ""}`} />
522 {label}
523 <span className="tabular-nums text-faint">{cards.length}</span>
524 {index < PIPELINE.length - 1 && <ArrowRight size={11} className="ml-auto text-line-strong" />}
525 </p>
526 {cards.length === 0 ? (
527 <p className="rounded-lg border border-dashed border-line/70 px-2 py-3 text-center text-[0.6875rem] text-faint">Empty</p>
528 ) : (
529 <ul className="space-y-1.5">
530 {cards.slice(0, 6).map((card) => (
531 <PipelineCard key={card.number} card={card} base={base} stage={stage} />
532 ))}
533 {cards.length > 6 && (
534 <li className="px-1 text-[0.6875rem] text-muted">
535 <Link to={`${base}/pulls`} className="hover:text-fg">
536 {cards.length - 6} more
537 </Link>
538 </li>
539 )}
540 </ul>
541 )}
542 </li>
543 );
544 })}
545 </ol>
546 </div>
547 );
548}
549
550/**
551 * Where an app's overview shows production, a library's shows what it
552 * publishes: each package with its latest version and how to install it,
553 * or, before there is one, how to publish a first version.
554 */
555function LibraryHead({
556 library,
557 project,
558 base,
559 workspace,
560}: {
561 library: { packages: PackageSummary[]; total: number; loaded: boolean };
562 project: Project | null;
563 base: string;
564 workspace: string;
565}) {
566 const guide = publishGuide(project?.ecosystem ?? null);
567 const name = project?.name ?? base.split("/").pop();
568 return (
569 <div className="p-5 sm:p-6">
570 <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
571 <PackageGlyph size={13} className="text-accent" />
572 Packages
573 </p>
574 {library.packages.length > 0 ? (
575 <>
576 <ul className="mt-3 space-y-4">
577 {library.packages.map((pkg) => {
578 const commands = installCommands(pkg, pkg.latest, "you");
579 return (
580 <li key={pkg.id} className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:gap-5">
581 <Link to={packagePath(pkg)} className="group flex min-w-0 items-center gap-3 lg:w-72 lg:shrink-0">
582 <PackageIcon ecosystem={pkg.ecosystem} size={32} />
583 <span className="min-w-0">
584 <span className="block truncate font-mono text-[0.9375rem] font-medium group-hover:text-accent">{packageName(pkg)}</span>
585 <span className="block text-xs text-muted">
586 {ECOSYSTEM_LABEL[pkg.ecosystem]} · {pkg.latest ?? "no versions yet"}
587 {pkg.versions > 1 && ` · ${pkg.versions} versions`}
588 </span>
589 </span>
590 </Link>
591 <div className="min-w-0 grow space-y-1.5">
592 {/* The workspace's registry first, or the tool installs a package of the same name from elsewhere. */}
593 {commands.registry && <CopyLine prompt text={commands.registry} />}
594 <CopyLine prompt text={commands.install} />
595 </div>
596 </li>
597 );
598 })}
599 </ul>
600 {library.total > library.packages.length && (
601 <Link to={`/${workspace}/-/packages`} className="mt-3 inline-block text-xs text-muted hover:text-fg">
602 All {library.total} packages
603 </Link>
604 )}
605 </>
606 ) : (
607 <>
608 <p className="mt-2 text-lg font-medium">{library.loaded ? "Not published yet" : "Packages could not be loaded just now"}</p>
609 {guide ? (
610 <>
611 <p className="mt-1 max-w-lg text-sm text-muted">
612 Publish a first version of {name} to the workspace's {guide.label} registry, for others to install
613 {guide.start ? ":" : "."}
614 </p>
615 {guide.start && (
616 <div className="mt-3 max-w-lg">
617 <CopyLine prompt text={guide.start} />
618 </div>
619 )}
620 <a href={guide.guide} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
621 Publishing to {guide.label} <ArrowUpRight size={13} />
622 </a>
623 </>
624 ) : (
625 <>
626 <p className="mt-1 max-w-lg text-sm text-muted">Publish a first version of {name} to one of the workspace's registries:</p>
627 <p className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm">
628 {PUBLISH_GUIDES.map((one) => (
629 <a key={one.label} href={one.guide} className="inline-flex items-center gap-1 text-accent hover:underline">
630 {one.label} <ArrowUpRight size={13} />
631 </a>
632 ))}
633 </p>
634 </>
635 )}
636 </>
637 )}
638 </div>
639 );
640}
641
642function Stat({ label, value, to }: { label: string; value: ReactNode; to?: string }) {
643 const body = (
644 <>
645 <span className="block text-[0.6875rem] text-faint">{label}</span>
646 <span className="mt-0.5 block truncate text-[0.8125rem] font-medium">{value}</span>
647 </>
648 );
649 return to ? (
650 <Link to={to} className="min-w-0 px-5 py-3 transition-colors hover:bg-raised/50">
651 {body}
652 </Link>
653 ) : (
654 <div className="min-w-0 px-5 py-3">{body}</div>
655 );
656}
657
658export default function ProjectOverview({ loaderData, actionData, params }: Route.ComponentProps) {
659 return (
660 <Suspense fallback={<OverviewSkeleton />}>
661 <Await resolve={loaderData.overview} errorElement={<OverviewFailed />}>
662 {(overview) => (
663 // Faded in over its outline, not switched for it.
664 <div className="animate-fade-in">
665 <Overview loaderData={overview} actionData={actionData} params={params} />
666 </div>
667 )}
668 </Await>
669 </Suspense>
670 );
671}
672
673/** The overview's sections as they will sit, while they stream in. */
674function OverviewSkeleton() {
675 return (
676 <Loading className="space-y-8" aria-label="Loading the overview">
677 {/* Production: its screenshot beside where it is live, then the facts under them. */}
678 <section aria-hidden="true" className="overflow-hidden rounded-2xl border border-line bg-surface">
679 <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
680 <Skeleton className="aspect-[16/10] w-full shrink-0 rounded-lg sm:w-72" />
681 <div className="min-w-0 grow">
682 <Skeleton className="h-3 w-24" />
683 <Skeleton className="mt-3 h-5 w-72 max-w-full" />
684 <Skeleton className="mt-3 h-3 w-48 max-w-full" />
685 </div>
686 </div>
687 <div className="grid grid-cols-2 gap-px border-t border-line bg-line sm:grid-cols-4">
688 {Array.from({ length: 4 }, (_, index) => (
689 <div key={index} className="bg-surface px-5 py-4">
690 <Skeleton className="h-3 w-16" />
691 <Skeleton className="mt-2.5 h-4 w-28" />
692 </div>
693 ))}
694 </div>
695 </section>
696 {/* Right now: the work, column by column. */}
697 <div aria-hidden="true" className="grid grid-cols-2 gap-2 sm:grid-cols-5">
698 {Array.from({ length: 5 }, (_, index) => (
699 <div key={index} className="rounded-xl border border-line bg-surface p-3">
700 <Skeleton className="h-3 w-20" />
701 <Skeleton className="mt-3 h-14 rounded-lg" />
702 </div>
703 ))}
704 </div>
705 <div className="grid gap-6 lg:grid-cols-2">
706 <section className="rounded-2xl border border-line bg-surface">
707 <SkeletonRows rows={4} />
708 </section>
709 <section className="rounded-2xl border border-line bg-surface">
710 <SkeletonRows rows={4} />
711 </section>
712 </div>
713 </Loading>
714 );
715}
716
717function OverviewFailed() {
718 return (
719 <p className="rounded-2xl border border-line bg-surface p-6 text-sm text-muted">
720 The overview could not be loaded just now. Reload the page to try again.
721 </p>
722 );
723}
724
725function Overview({
726 loaderData,
727 actionData,
728 params,
729}: {
730 loaderData: Loaded;
731 actionData: Route.ComponentProps["actionData"];
732 params: Route.ComponentProps["params"];
733}) {
734 const { member, project, settings, builds, live, commit, open, dependencies, agentsLive, columns, needs, landed, groups, health, knows, checklist, branches, library } =
735 loaderData;
736 const base = `/${params.owner}/${params.repo}`;
737 const production = live.find((app) => app.kind === "production") ?? null;
738 // The project's own domain, once it is active, is where production is visited.
739 const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null;
740 const previews = live.filter((app) => app.kind === "preview");
741 const latestProduction = builds.find((build) => build.kind === "production") ?? null;
742 const plan = project ? checklistPlan(project).plan : "production";
743 const canChange = member && loaderData.can.manage_settings;
744 // Its homepage and docs beside what it is, unless the card below already links there.
745 const headUrl =
746 plan === "production"
747 ? (productionUrl ?? settings?.productionUrl)
748 : plan === "elsewhere"
749 ? (loaderData.deployment?.url ?? project?.productionUrl)
750 : plan === "docs"
751 ? (project?.links.docs ?? project?.productionUrl ?? project?.links.homepage)
752 : plan === "other"
753 ? (project?.links.homepage ?? project?.links.docs)
754 : null;
755 const stripLinks = project ? linksToShow({ ...project.links, custom: [] }, [headUrl]) : [];
756 const addresses = useAddresses();
757 const source = project?.source.kind === "hosted" ? project.source : null;
758 const moving = agentsLive.length > 0 || columns.working.length + columns.checking.length > 0;
759 useLiveRefresh(moving);
760 const ages = health.ages;
761 const oldest = ages ? Math.max(1, ...ages.map((a) => a.count)) : 1;
762
763 return (
764 <div className="space-y-8">
765 {loaderData.github && <GithubLinkStrip link={loaderData.github} />}
766 {/* What the project is, running, and where its code is. */}
767 <section className="overflow-hidden rounded-2xl border border-line bg-surface">
768 {project && (
769 <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-line px-5 py-2.5 text-xs sm:px-6">
770 <KindMenu project={project} label={kindLabel(project)} canChange={canChange} />
771 {stripLinks.map((link) => (
772 <a key={link.key} href={link.url} rel="noopener noreferrer" className="inline-flex min-w-0 items-center gap-1.5 text-muted hover:text-fg">
773 {link.type === "docs" ? <BookOpen size={12} className="shrink-0 text-faint" /> : <Globe size={12} className="shrink-0 text-faint" />}
774 <span className="truncate">{link.type === "docs" ? `Docs · ${bare(link.url)}` : link.label}</span>
775 </a>
776 ))}
777 </div>
778 )}
779 {plan === "elsewhere" && project ? (
780 <ElsewhereHead
781 project={project}
782 base={base}
783 canChange={canChange}
784 canDeploy={member && loaderData.can.manage_integrations}
785 deployment={loaderData.deployment}
786 commit={commit}
787 />
788 ) : plan === "unknown" && project ? (
789 <WhereItRuns project={project} base={base} canChange={canChange} canDeploy={member && loaderData.can.manage_integrations} />
790 ) : plan === "docs" && project ? (
791 <DocsHead project={project} base={base} canChange={canChange} canDeploy={member && loaderData.can.manage_integrations} />
792 ) : plan === "other" && project ? (
793 <OtherHead project={project} base={base} canChange={canChange} />
794 ) : library ? (
795 <LibraryHead library={library} project={project} base={base} workspace={params.owner} />
796 ) : (
797 <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
798 {member && (production || settings?.enabled) && (
799 <ProductionShot
800 src={production ? `${base}/production.jpg?v=${shotVersion(production)}` : null}
801 href={production ? (productionUrl ?? production.url) : null}
802 label={host(production ? (productionUrl ?? production.url) : (settings?.productionUrl ?? ""))}
803 className="w-full shrink-0 sm:w-60 lg:w-72"
804 />
805 )}
806 <div className="flex min-w-0 grow flex-wrap items-start justify-between gap-4">
807 <div className="min-w-0">
808 <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
809 <Rocket size={13} className="text-accent" />
810 Production
811 </p>
812 {production ? (
813 <>
814 <a
815 href={productionUrl ?? production.url}
816 className="mt-2 flex items-center gap-1.5 truncate font-mono text-lg font-medium hover:text-accent"
817 >
818 {host(productionUrl ?? production.url)}
819 <ArrowUpRight size={16} className="shrink-0 text-faint" />
820 </a>
821 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
822 {latestProduction && <StatusDot status={latestProduction.status} label={latestProduction.status === "ready" ? "Live" : undefined} />}
823 <span className="inline-flex items-center gap-1 font-mono">
824 <GitCommitHorizontal size={13} className="text-faint" />
825 {production.commit.slice(0, 7)}
826 </span>
827 <span>
828 deployed <TimeAgo at={production.deployedAt} />
829 </span>
830 {settings?.primaryDomain && <span className="text-faint">also at {host(production.url)}</span>} </p>
831 </>
832 ) : settings?.enabled ? (
833 <>
834 <p className="mt-2 font-mono text-lg text-muted">{host(settings.productionUrl)}</p>
835 <p className="mt-1.5 text-xs text-muted">
836 {latestProduction ? (
837 <StatusDot status={latestProduction.status} label={latestProduction.status === "failed" ? "The last build failed" : undefined} />
838 ) : (
839 "Not deployed yet. Push to the default branch, or deploy it now."
840 )}
841 </p>
842 </>
843 ) : (
844 <>
845 <p className="mt-2 text-lg font-medium">Deployments are off</p>
846 <p className="mt-1 max-w-lg text-sm text-muted">
847 Nothing builds or runs until you turn them on. Then {project?.name ?? params.repo} goes up on g1t.page:
848 production from the default branch, and a live preview for every pull request. It runs only while
849 someone visits.
850 </p>
851 </>
852 )}
853 </div>
854 {member && (
855 <div className="flex shrink-0 items-center gap-2">
856 {production && (
857 <ButtonLink to={productionUrl ?? production.url} variant="accent" reloadDocument>
858 Visit
859 <ArrowUpRight size={14} />
860 </ButtonLink>
861 )}
862 {settings?.enabled ? (
863 loaderData.can.run && <Form method="post">
864 <Hint label="Build production again from the default branch">
865 <SubmitButton variant="quiet" name="intent" value="redeploy" pending="Redeploying…">
866 <RotateCw size={14} />
867 Redeploy
868 </SubmitButton>
869 </Hint>
870 </Form>
871 ) : loaderData.can.manage_integrations && (
872 <ButtonLink to={`${base}/settings/deployments`} variant="accent">
873 <Rocket size={14} />
874 Turn on deployments
875 </ButtonLink>
876 )}
877 </div>
878 )}
879 </div>
880 </div>
881 )}
882 {actionData && (
883 <p className={`border-t border-line px-6 py-2.5 text-sm ${"error" in actionData ? "text-danger" : "text-success"}`}>
884 {"error" in actionData ? actionData.error : actionData.notice}
885 </p>
886 )}
887 <div className="grid grid-cols-2 divide-line border-t border-line text-sm sm:grid-cols-4 sm:divide-x">
888 <Stat
889 label="Source"
890 to={`${base}/code`}
891 value={
892 source ? (
893 <span className="inline-flex items-center gap-1.5 font-mono">
894 {project?.private ? <Lock size={12} className="text-faint" /> : <Code2 size={12} className="text-faint" />}
895 {source.repo.name}
896 {source.rootDir ? `/${source.rootDir}` : ""}
897 </span>
898 ) : (
899 "—"
900 )
901 }
902 />
903 <Stat
904 label="Default branch"
905 to={`${base}/commits`}
906 value={
907 <span className="inline-flex items-center gap-1.5 font-mono">
908 <GitBranch size={12} className="text-faint" />
909 {source?.defaultBranch ?? "main"}
910 </span>
911 }
912 />
913 <Stat
914 label="Latest commit"
915 to={commit ? `${base}/commit/${commit.hash}` : undefined}
916 value={
917 commit ? (
918 <Hint label={commit.message.split("\n")[0]}>
919 <span>
920 <span className="font-mono">{commit.hash.slice(0, 7)}</span>{" "}
921 <span className="font-normal text-muted">
922 <TimeAgo at={commit.authoredAt} />
923 </span>
924 </span>
925 </Hint>
926 ) : (
927 "No commits yet"
928 )
929 }
930 />
931 <Stat
932 label={member && settings?.enabled && !library ? "Last deploy" : "Open"}
933 to={member && settings?.enabled && !library ? `${base}/deployments` : `${base}/pulls`}
934 value={
935 member && settings?.enabled && !library ? (
936 latestProduction ? (
937 <span className="inline-flex items-center gap-2">
938 <StatusDot status={latestProduction.status} />
939 <span className="font-normal text-muted">
940 <TimeAgo at={latestProduction.createdAt} />
941 </span>
942 </span>
943 ) : (
944 "None yet"
945 )
946 ) : (
947 `${open.pulls} pull requests · ${open.issues} issues`
948 )
949 }
950 />
951 </div>
952 {commit && (
953 <p className="truncate border-t border-line px-5 py-2.5 text-xs text-muted sm:px-6">
954 <GitCommitHorizontal size={12} className="mr-1.5 inline text-faint" />
955 {commit.message.split("\n")[0]} <span className="text-faint">· {commit.author.name}</span>
956 </p>
957 )}
958 </section>
959
960 {checklist && <ProductionChecklist base={base} items={checklist} title={loaderData.checklistTitle} />}
961
962 <Panel
963 title="Right now"
964 icon={<span className={`block size-2 rounded-full ${agentsLive.length > 0 ? "animate-pulse bg-accent" : "bg-line-strong"}`} />}
965 count={agentsLive.length}
966 all={{ to: `${base}/agents`, label: "Agents" }}
967 >
968 {agentsLive.length > 0 && (
969 <ul className="mb-3 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
970 {agentsLive.map((run) => (
971 <li key={run.id} className="min-w-0 rounded-xl border border-accent/30 bg-surface px-3.5 py-3">
972 <div className="flex items-center gap-2 text-sm">
973 <Avatar name={run.agent} size={18} />
974 <span className="font-medium">{run.agent}</span>
975 <span className="text-xs text-muted">{RUN_KIND_LABEL[run.kind].toLowerCase()}</span>
976 <span className="grow" />
977 <span className="text-xs text-muted">
978 <Elapsed from={run.startedAt ?? run.createdAt} />
979 </span>
980 </div>
981 <Link
982 to={run.number != null ? `${base}/pull/${run.number}` : `${base}/agents/runs/${run.id}`}
983 className="mt-1 block truncate text-[0.8125rem] hover:text-accent"
984 >
985 {run.title ?? "A run"} {run.number != null && <span className="text-faint">#{run.number}</span>}
986 </Link>
987 {run.step && (
988 <Hint label={run.step}>
989 <p className="mt-1 truncate font-mono text-[0.6875rem] text-fg/70">
990 <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-accent align-middle" />
991 {run.step}
992 </p>
993 </Hint>
994 )}
995 <p className="mt-1.5 flex items-center gap-3 text-[0.6875rem] text-faint">
996 {formatCost(run.costUsd) && <span>{formatCost(run.costUsd)} so far</span>}
997 <Link to={`${base}/agents/runs/${run.id}`} className="hover:text-fg">
998 {run.stepCount} steps
999 </Link>
1000 {run.number != null && (
1001 <Link to={`${base}/sessions/${run.number}`} className="hover:text-fg">
1002 Session
1003 </Link>
1004 )}
1005 </p>
1006 </li>
1007 ))}
1008 </ul>
1009 )}
1010 {!loaderData.pullsLoaded ? (
1011 <Unavailable what="Pull requests" />
1012 ) : Object.values(columns).every((c) => c.length === 0) ? (
1013 <Quiet
1014 action={
1015 member ? (
1016 <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">
1017 <Bot size={12} /> Open an issue for an agent
1018 </Link>
1019 ) : null
1020 }
1021 >
1022 Nothing is moving. Open an issue and hand it to g1t, or push a branch and open a pull request.
1023 </Quiet>
1024 ) : (
1025 <Pipeline columns={columns} base={base} />
1026 )}
1027 </Panel>
1028
1029 <div className="grid gap-x-8 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
1030 <div className="min-w-0 space-y-8">
1031 {needs.length > 0 && (
1032 <Panel title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}>
1033 <NeedsList needs={needs} limit={5} />
1034 </Panel>
1035 )}
1036
1037 <Suspense
1038 fallback={
1039 <Panel title="Active branches" icon={<GitBranch size={14} />}>
1040 <div aria-busy="true">
1041 <SkeletonRows rows={3} rowClassName="h-12" />
1042 </div>
1043 </Panel>
1044 }
1045 >
1046 <Await
1047 resolve={branches}
1048 errorElement={
1049 <Panel title="Active branches" icon={<GitBranch size={14} />}>
1050 <Unavailable what="Branches" />
1051 </Panel>
1052 }
1053 >
1054 {(branches) => (
1055 <Panel
1056 title="Active branches"
1057 icon={<GitBranch size={14} />}
1058 count={branches && branches !== "slow" ? branches.total || null : null}
1059 all={{ to: `${base}/branches`, label: "All branches" }}
1060 >
1061 {branches === "slow" ? (
1062 <Quiet>
1063 Its branches are taking a while to compare.{" "}
1064 <Link to={`${base}/branches`} className="text-accent hover:underline">
1065 See them on Branches
1066 </Link>
1067 .
1068 </Quiet>
1069 ) : !branches ? (
1070 <Unavailable what="Branches" />
1071 ) : branches.shown.length === 0 ? (
1072 <Quiet>
1073 Only {branches.main} so far. Branches pushed here show with how far each has moved from {branches.main}, and
1074 its pull request and preview.
1075 </Quiet>
1076 ) : (
1077 <>
1078 <ActiveBranches branches={branches.shown} base={base} main={branches.main} />
1079 {branches.total > branches.shown.length && (
1080 <p className="mt-2 px-1 text-xs text-faint">
1081 The {branches.shown.length} most recently changed of {branches.total} branches.
1082 </p>
1083 )}
1084 </>
1085 )}
1086 </Panel>
1087 )}
1088 </Await>
1089 </Suspense>
1090
1091 <Panel title="Recent changes" icon={<GitMerge size={14} />} all={{ to: `${base}/pulls?state=closed`, label: "All landed" }}>
1092 {landed.length === 0 ? (
1093 <Quiet>Nothing has landed yet. Merged pull requests show here with who made them and why.</Quiet>
1094 ) : (
1095 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
1096 {landed.map((pull) => {
1097 const byAgent = pull.runtime === "hosted" || madeByG1t(pull);
1098 return (
1099 <li key={pull.id} className="flex items-start gap-3 px-4 py-3">
1100 <GitMerge size={15} className="mt-0.5 shrink-0 text-merged" />
1101 <span className="min-w-0 grow">
1102 <Link to={`${base}/pull/${pull.number}`} prefetch="intent" className="block truncate text-sm font-medium hover:text-accent">
1103 {pull.title}
1104 </Link>
1105 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
1106 <span className="font-mono text-faint">#{pull.number}</span>
1107 <span>·</span>
1108 <span className="inline-flex items-center gap-1">
1109 <Avatar name={pull.agent} size={13} />
1110 {pull.requestedBy
1111 ? `made by ${pull.author.username} for ${pull.requestedBy.username}`
1112 : byAgent
1113 ? `made by ${pull.agent}`
1114 : `by ${pull.author.username}`}
1115 </span>
1116 {pull.issue != null && (
1117 <>
1118 <span>· for</span>
1119 <Link to={`${base}/issues/${pull.issue}`} className="hover:text-fg">
1120 #{pull.issue}
1121 </Link>
1122 </>
1123 )}
1124 {pull.mergedBy && <span>· landed by {pull.mergedBy}</span>}
1125 <span>
1126 · <TimeAgo at={pull.mergedAt ?? pull.updatedAt} />
1127 </span>
1128 </span>
1129 </span>
1130 <span className="hidden shrink-0 text-xs text-faint sm:block">
1131 <ChangeSize files={pull.files} />
1132 </span>
1133 {byAgent && (
1134 <Link to={`${base}/sessions/${pull.number}`} className="shrink-0 text-xs text-muted hover:text-fg">
1135 Session
1136 </Link>
1137 )}
1138 </li>
1139 );
1140 })}
1141 </ul>
1142 )}
1143 </Panel>
1144
1145 {loaderData.commits.length > 0 && (
1146 <Panel
1147 title={`Latest on ${source?.defaultBranch ?? "main"}`}
1148 icon={<GitCommitHorizontal size={14} />}
1149 all={{ to: `${base}/commits`, label: "All commits" }}
1150 >
1151 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
1152 {loaderData.commits.map((one) => (
1153 <li key={one.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
1154 <Avatar name={one.author} size={18} />
1155 <span className="min-w-0 grow">
1156 <Link to={`${base}/commit/${one.hash}`} className="block truncate hover:text-accent">
1157 {one.message}
1158 </Link>
1159 <span className="text-xs text-muted">{one.author}</span>
1160 </span>
1161 <Link to={`${base}/commit/${one.hash}`} className="hidden shrink-0 font-mono text-xs text-faint hover:text-fg sm:block">
1162 {one.hash.slice(0, 7)}
1163 </Link>
1164 <span className="w-16 shrink-0 text-right text-xs text-faint">
1165 <TimeAgo at={one.at} />
1166 </span>
1167 </li>
1168 ))}
1169 </ul>
1170 </Panel>
1171 )}
1172
1173 <Panel title="Activity" icon={<ActivityIcon size={14} />}>
1174 {loaderData.eventsLoaded ? (
1175 <ActivityFeed groups={groups} showRepo={false} limit={8} empty="Nothing has happened here yet." />
1176 ) : (
1177 <Unavailable what="Activity" />
1178 )}
1179 </Panel>
1180
1181 {previews.length > 0 && (
1182 <Panel title="Previews" icon={<GitBranch size={14} />} count={previews.length} all={{ to: `${base}/deployments`, label: "Deployments" }}>
1183 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
1184 {previews.map((app) => (
1185 <li key={app.url} className="flex items-center gap-3 px-4 py-2.5 text-sm">
1186 <span className="min-w-0 grow">
1187 <a href={app.url} className="block truncate font-mono text-[0.8125rem] hover:text-accent">
1188 {host(app.url)}
1189 </a>
1190 <span className="text-xs text-muted">
1191 {app.branch}
1192 {app.number != null && (
1193 <>
1194 {" · "}
1195 <Link to={`${base}/pull/${app.number}`} className="hover:underline">
1196 #{app.number}
1197 </Link>
1198 </>
1199 )}
1200 </span>
1201 </span>
1202 <span className="shrink-0 text-xs text-faint">
1203 <TimeAgo at={app.deployedAt} />
1204 </span>
1205 </li>
1206 ))}
1207 </ul>
1208 </Panel>
1209 )}
1210 </div>
1211
1212 <aside className="space-y-4 text-sm">
1213 {project && (
1214 <section className="rounded-xl border border-line bg-surface p-5">
1215 <div className="flex items-center justify-between">
1216 <h2 className="text-sm font-semibold">About</h2>
1217 {canChange && <AboutEditor project={project} />}
1218 </div>
1219 <p className={`mt-2.5 text-sm ${project.description ? "text-fg-soft" : "text-faint"}`}>
1220 {project.description ?? "No description."}
1221 </p>
1222 <LinkList links={project.links} className="mt-3" />
1223 <ul className="mt-4 space-y-2 text-xs text-muted">
1224 <li className="flex items-center gap-2">
1225 {project.kind === "library" || project.kind === "tool" ? (
1226 <PackageGlyph size={13} className="shrink-0 text-faint" />
1227 ) : project.kind === "docs" ? (
1228 <BookOpen size={13} className="shrink-0 text-faint" />
1229 ) : (
1230 <Rocket size={13} className="shrink-0 text-faint" />
1231 )}
1232 <Hint label={project.kindReason.detail}>
1233 <span>{kindLabel(project)}</span>
1234 </Hint>
1235 </li>
1236 {loaderData.release && (
1237 <li className="flex items-center gap-2">
1238 <Tag size={13} className="shrink-0 text-faint" />
1239 <Link to={`${base}/tags`} className="hover:text-fg">
1240 <span className="font-mono text-fg-soft">{loaderData.release.name}</span>
1241 {loaderData.release.at && (
1242 <span className="text-faint">
1243 {" · "}
1244 <TimeAgo at={loaderData.release.at} />
1245 </span>
1246 )}
1247 </Link>
1248 </li>
1249 )}
1250 {loaderData.languages && loaderData.languages.length > 0 && (
1251 <li className="flex items-center gap-2">
1252 <Code2 size={13} className="shrink-0 text-faint" />
1253 <span>
1254 {loaderData.languages
1255 .slice(0, 3)
1256 .map((language) => `${language.name} ${Math.round(language.share * 100)}%`)
1257 .join(" · ")}
1258 </span>
1259 </li>
1260 )}
1261 </ul>
1262 {loaderData.contributors && loaderData.contributors.length > 0 && (
1263 <div className="mt-4 flex flex-wrap gap-1">
1264 {loaderData.contributors.slice(0, 12).map((person) => (
1265 <Hint key={person.username} label={person.username}>
1266 <Link to={`/${person.username}`}>
1267 <Avatar name={person.username} size={22} />
1268 </Link>
1269 </Hint>
1270 ))}
1271 </div>
1272 )}
1273 </section>
1274 )}
1275
1276 {member && (
1277 <section className="rounded-xl border border-line bg-surface p-5">
1278 <div className="flex items-center justify-between">
1279 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
1280 <Brain size={14} className="text-accent" />
1281 What agents know here
1282 </h2>
1283 <Link to={`${base}/memory`} className="text-xs text-muted hover:text-fg">
1284 Memory
1285 </Link>
1286 </div>
1287 {!loaderData.memoriesLoaded ? (
1288 <p className="mt-3 text-xs text-muted">Memory could not be loaded just now.</p>
1289 ) : knows.length === 0 ? (
1290 <p className="mt-3 text-xs leading-5 text-muted">
1291 Nothing yet. Agents note conventions, decisions and traps as they work, and every agent starting here reads them.
1292 </p>
1293 ) : (
1294 <ul className="mt-3 space-y-2.5">
1295 {knows.map((memory) => (
1296 <li key={memory.id} className="text-xs leading-5">
1297 <p className="line-clamp-3 text-fg-soft">
1298 {memory.pinned && <Pin size={11} className="mr-1 inline text-accent" />}
1299 <InlineMarkdown text={memory.text} />
1300 </p>
1301 <p className="text-faint">
1302 {memory.kind} · {memory.createdBy} · <TimeAgo at={memory.createdAt} />
1303 </p>
1304 </li>
1305 ))}
1306 {loaderData.memoryCount > knows.length && (
1307 <li>
1308 <Link to={`${base}/memory`} className="text-xs text-muted hover:text-fg">
1309 {loaderData.memoryCount - knows.length} more
1310 </Link>
1311 </li>
1312 )}
1313 </ul>
1314 )}
1315 </section>
1316 )}
1317
1318 <section className="rounded-xl border border-line bg-surface p-5">
1319 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
1320 <HeartPulse size={14} className="text-faint" />
1321 Health
1322 </h2>
1323 <div className="mt-4 space-y-4 text-xs">
1324 <div>
1325 <div className="flex items-baseline justify-between">
1326 <span className="text-muted">Checks passing</span>
1327 <span className="font-medium tabular-nums">{percent(health.passRate)}</span>
1328 </div>
1329 <div className="mt-1.5">
1330 <Meter value={health.passRate} tone={health.passRate != null && health.passRate < 0.7 ? "bg-warn" : "bg-success"} />
1331 </div>
1332 <p className="mt-1 text-faint">
1333 {health.checkRuns
1334 ? `${health.checkRuns} recent runs · ${percent(health.firstPass.rate)} pass on the first try`
1335 : "No checks have run recently."}
1336 </p>
1337 </div>
1338 {member && (plan === "production" || builds.length > 0) && (
1339 <div>
1340 <div className="flex items-baseline justify-between">
1341 <span className="text-muted">Deploys</span>
1342 <Link to={`${base}/deployments`} className="text-faint hover:text-fg">
1343 {builds.length ? `last ${Math.min(builds.length, 20)}` : "none yet"}
1344 </Link>
1345 </div>
1346 <div className="mt-1.5">
1347 <DeployStrip builds={builds} base={base} />
1348 </div>
1349 </div>
1350 )}
1351 <div>
1352 <div className="flex items-baseline justify-between">
1353 <span className="text-muted">Open issues by age</span>
1354 <Link to={`${base}/issues`} className="font-medium tabular-nums hover:text-accent">
1355 {open.issues}
1356 </Link>
1357 </div>
1358 {ages ? (
1359 <ul className="mt-2 space-y-1.5">
1360 {ages.map((bucket) => (
1361 <li key={bucket.label} className="grid grid-cols-[6.5rem_1fr_1.5rem] items-center gap-2">
1362 <span className="text-faint">{bucket.label}</span>
1363 <span className="block h-1.5 overflow-hidden rounded-full bg-line">
1364 <span
1365 className={`block h-full rounded-full ${bucket.label === "Older" ? "bg-warn" : "bg-fg-soft/60"}`}
1366 style={{ width: `${(bucket.count / oldest) * 100}%` }}
1367 />
1368 </span>
1369 <span className="text-right tabular-nums text-muted">{bucket.count}</span>
1370 </li>
1371 ))}
1372 </ul>
1373 ) : (
1374 <p className="mt-1 text-faint">Issues could not be loaded just now.</p>
1375 )}
1376 </div>
1377 </div>
1378 </section>
1379
1380 <section className="rounded-xl border border-line bg-surface p-5">
1381 <div className="flex items-center justify-between">
1382 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
1383 <Network size={14} className="text-faint" />
1384 Dependencies
1385 </h2>
1386 {member && (
1387 <Link to={`${base}/settings/dependencies`} className="text-xs text-muted hover:text-fg">
1388 Manage
1389 </Link>
1390 )}
1391 </div>
1392 {!dependencies ? (
1393 <p className="mt-3 text-xs text-muted">Dependencies could not be loaded just now.</p>
1394 ) : dependencies.dependsOn.length === 0 && dependencies.usedBy.length === 0 ? (
1395 <p className="mt-3 text-xs leading-5 text-muted">
1396 Uses no other project, and none uses it. Declare one, and builds get its address and agents know what depends on
1397 what.
1398 </p>
1399 ) : (
1400 <div className="mt-3 space-y-3 text-xs">
1401 {dependencies.dependsOn.length > 0 && (
1402 <div>
1403 <p className="flex items-center gap-1 text-muted">
1404 <ArrowUpRight size={12} /> Uses
1405 </p>
1406 <ul className="mt-1.5 space-y-1">
1407 {dependencies.dependsOn.map((d) => (
1408 <li key={d.slug} className="flex items-center justify-between gap-2">
1409 <Link to={`/${params.owner}/${d.slug}`} className="font-medium hover:underline">
1410 {d.name}
1411 </Link>
1412 {d.as && <code className="font-mono text-faint">{d.as}</code>}
1413 </li>
1414 ))}
1415 </ul>
1416 </div>
1417 )}
1418 {dependencies.usedBy.length > 0 && (
1419 <div>
1420 <p className="flex items-center gap-1 text-muted">
1421 <ArrowDownLeft size={12} /> Used by
1422 </p>
1423 <ul className="mt-1.5 space-y-1">
1424 {dependencies.usedBy.map((d) => (
1425 <li key={d.slug}>
1426 <Link to={`/${params.owner}/${d.slug}`} className="font-medium hover:underline">
1427 {d.name}
1428 </Link>
1429 </li>
1430 ))}
1431 </ul>
1432 </div>
1433 )}
1434 </div>
1435 )}
1436 </section>
1437
1438 {source && (
1439 <section className="rounded-xl border border-line bg-surface p-5">
1440 <h2 className="text-sm font-semibold">Clone</h2>
1441 <div className="mt-3">
1442 <CopyLine text={`git clone ${cloneUrl(addresses, `${source.repo.namespace}/${source.repo.name}`)}`} />
1443 </div>
1444 </section>
1445 )}
1446 </aside>
1447 </div>
1448 </div>
1449 );
1450}