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