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