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