Skip to content

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

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