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