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