Skip to content

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

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