Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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