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