g1t/apps/web/app/routes/workspace/overview.tsx

400 lines17,460 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Projects: what a workspace builds and runs, first on every page1import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Plus, Rocket } from "lucide-react";
Agents as a team: lifecycle, merge queue, billing and a new shell2import { Link } from "react-router";
3
A project is an app or a library: libraries show their package and how to ship a release, not production4import type { PackageSummary, Project, ProjectDeploys, User } from "@g1t/contracts";
Projects: what a workspace builds and runs, first on every page5
Agents as a team: lifecycle, merge queue, billing and a new shell6import type { Route } from "./+types/overview";
Projects: what a workspace builds and runs, first on every page7import { host, StatusDot } from "../../components/deploy";
Agents as a team: lifecycle, merge queue, billing and a new shell8import { Avatar, ButtonLink, CopyLine, Pill, TimeAgo } from "../../components/ui";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look9import { UsageCard } from "../../components/usage-card";
10import { PullIcon } from "../../components/work-icons";
11import { planStatus, type UsageGlance, usageGlance } from "../../lib/billing";
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent12import { openedBy } from "../../lib/opened-by";
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue13import { cloneUrl, useAddresses } from "../../lib/addresses";
A project is an app or a library: libraries show their package and how to ship a release, not production14import { libraryPackages, packageLine, packagePath } from "../../lib/project-kind";
15import { billing, deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell16import { getViewer, roleIn } from "../../lib/session.server";
17
Projects: what a workspace builds and runs, first on every page18/** Projects whose open issues and pull requests are counted. */
Agents as a team: lifecycle, merge queue, billing and a new shell19const MAX_COUNTED = 30;
Projects: what a workspace builds and runs, first on every page20/** Projects whose pull requests are listed under "In progress". */
Agents as a team: lifecycle, merge queue, billing and a new shell21const MAX_LISTED = 8;
22const MAX_PULLS = 8;
23const MAX_FACES = 8;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look24/** The trial as published, when the price book cannot be read. */
25const DEFAULT_TRIAL_MICROS = 5_000_000;
Agents as a team: lifecycle, merge queue, billing and a new shell26
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look27/**
28 * The month for the Usage card, for members only: what was spent, what
29 * pays first and what it went on. Fetched here, not with the sidebar, so
30 * only this page pays for it; a billing service that cannot answer leaves
31 * the card out rather than the page.
32 */
33async function usageFor(slug: string, viewer: User | null): Promise<UsageGlance | null> {
34 const now = new Date();
35 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
36 const [account, usage, features, entitlements, limit, book] = await Promise.all([
37 billing.account(slug, viewer).catch(() => null),
38 billing.usage(slug, viewer, monthStart).catch(() => null),
39 billing.features(slug, viewer).catch(() => null),
40 billing.entitlements(slug).catch(() => null),
41 billing.limit(slug, viewer).catch(() => null),
42 billing.prices().catch(() => null),
43 ]);
44 if (!account?.ok || !usage?.ok) return null;
45 // Without payments set up (a g1t run without billing), there is no plan to show.
46 if (!account.value.status.enabled && !usage.value.free) return null;
47 const plan = features?.ok ? (features.value.find((state) => state.plan.feature === "plan") ?? null) : null;
48 return usageGlance({
49 usage: usage.value,
50 status: planStatus(plan, entitlements),
51 entitlements,
52 limit: limit?.ok ? limit.value : null,
53 trialMicros: book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS,
54 });
55}
56
Agents as a team: lifecycle, merge queue, billing and a new shell57export async function loader({ params, context }: Route.LoaderArgs) {
58 const viewer = getViewer(context);
59 const slug = params.owner.toLowerCase();
60 const role = roleIn(viewer, slug);
A project is an app or a library: libraries show their package and how to ship a release, not production61 const [listed, members, deploys, usage, published] = await Promise.all([
Projects: what a workspace builds and runs, first on every page62 projectsApi.list(slug, viewer),
Agents as a team: lifecycle, merge queue, billing and a new shell63 role ? identity.listMembers(slug, viewer) : null,
Projects: what a workspace builds and runs, first on every page64 role ? deployments.overview(slug, viewer) : null,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look65 role ? usageFor(slug, viewer) : null,
A project is an app or a library: libraries show their package and how to ship a release, not production66 // One listing for every card: a library's card shows its package where an app's shows production.
67 packages.list(slug, viewer).catch(() => null),
Agents as a team: lifecycle, merge queue, billing and a new shell68 ]);
Projects: what a workspace builds and runs, first on every page69 const projects = listed.ok ? listed.value : [];
Agents as a team: lifecycle, merge queue, billing and a new shell70
Projects: what a workspace builds and runs, first on every page71 const pathOf = (project: Project) =>
72 project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug };
73 const counted = projects.slice(0, MAX_COUNTED);
74 const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer)));
Agents as a team: lifecycle, merge queue, billing and a new shell75 const open: Record<string, { issues: number; pulls: number }> = {};
Projects: what a workspace builds and runs, first on every page76 counted.forEach((project, i) => {
Agents as a team: lifecycle, merge queue, billing and a new shell77 const result = counts[i];
Projects: what a workspace builds and runs, first on every page78 if (result.ok) open[project.id] = result.value;
Agents as a team: lifecycle, merge queue, billing and a new shell79 });
80
Projects: what a workspace builds and runs, first on every page81 const busy = counted.filter((project) => (open[project.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED);
82 const lists = await Promise.all(busy.map((project) => work.listPulls(pathOf(project), viewer, "open")));
Agents as a team: lifecycle, merge queue, billing and a new shell83 const pulls = lists
Projects: what a workspace builds and runs, first on every page84 .flatMap((result, i) => (result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : []))
Agents as a team: lifecycle, merge queue, billing and a new shell85 .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt))
86 .slice(0, MAX_PULLS);
87
Projects: what a workspace builds and runs, first on every page88 const bySlug: Record<string, ProjectDeploys> = {};
89 for (const entry of deploys?.ok ? deploys.value : []) bySlug[entry.slug] = entry;
90
A project is an app or a library: libraries show their package and how to ship a release, not production91 const byRepo = new Map<string, PackageSummary[]>();
92 for (const pkg of published?.ok ? published.value : []) {
93 if (pkg.repo) byRepo.set(pkg.repo.id, [...(byRepo.get(pkg.repo.id) ?? []), pkg]);
94 }
95 const packageOf: Record<string, PackageSummary> = {};
96 for (const project of projects) {
97 if (project.kind !== "library" || project.source.kind !== "hosted") continue;
98 const first = libraryPackages(byRepo.get(project.source.repoId) ?? [])[0];
99 if (first) packageOf[project.id] = first;
100 }
101
Projects: what a workspace builds and runs, first on every page102 return {
103 slug,
104 role,
105 projects,
106 open,
107 deploys: bySlug,
A project is an app or a library: libraries show their package and how to ship a release, not production108 packageOf,
Projects: what a workspace builds and runs, first on every page109 pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })),
110 members: members?.ok ? members.value : null,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look111 usage,
Projects: what a workspace builds and runs, first on every page112 };
Agents as a team: lifecycle, merge queue, billing and a new shell113}
114
115function Stat({ value, label }: { value: number; label: string }) {
116 return (
117 <div className="rounded-xl border border-line bg-surface px-4 py-3">
118 <p className="text-xl font-semibold tabular-nums tracking-tight">{value}</p>
119 <p className="text-xs text-muted">{label}</p>
120 </div>
121 );
122}
123
Projects: what a workspace builds and runs, first on every page124/** What a member sees in a workspace with no projects yet. */
Agents as a team: lifecycle, merge queue, billing and a new shell125function GetStarted({ slug }: { slug: string }) {
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue126 const addresses = useAddresses();
Agents as a team: lifecycle, merge queue, billing and a new shell127 return (
Projects: what a workspace builds and runs, first on every page128 <div className="rounded-2xl border border-line bg-surface p-8">
129 <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
130 <Box size={18} />
131 </span>
132 <h2 className="mt-4 text-lg font-semibold tracking-tight">Create your first project</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell133 <p className="mt-1.5 max-w-xl text-sm text-muted">
Projects: what a workspace builds and runs, first on every page134 A project is what you build and run: its code, the issues that say what should change, the agents and
135 people who change it, and where it is deployed.
Agents as a team: lifecycle, merge queue, billing and a new shell136 </p>
Projects: what a workspace builds and runs, first on every page137 <div className="mt-6 flex flex-wrap gap-3">
138 <ButtonLink to={`/new?workspace=${slug}`} variant="accent">
139 <Plus size={14} />
140 New project
141 </ButtonLink>
Agents as a team: lifecycle, merge queue, billing and a new shell142 </div>
Projects: what a workspace builds and runs, first on every page143 <p className="mt-6 text-sm text-muted">Or push code you already have, and it becomes a project:</p>
Agents as a team: lifecycle, merge queue, billing and a new shell144 <div className="mt-2 max-w-xl">
Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue145 <CopyLine prompt text={`git push ${cloneUrl(addresses, `${slug}/my-project`)} main`} />
Agents as a team: lifecycle, merge queue, billing and a new shell146 </div>
147 </div>
148 );
149}
150
Projects: what a workspace builds and runs, first on every page151function ProjectCard({
152 project,
153 open,
154 deploys,
A project is an app or a library: libraries show their package and how to ship a release, not production155 pkg,
Projects: what a workspace builds and runs, first on every page156 member,
157}: {
158 project: Project;
159 open: { issues: number; pulls: number } | undefined;
160 deploys: ProjectDeploys | undefined;
A project is an app or a library: libraries show their package and how to ship a release, not production161 /** For a library, the package it publishes. */
162 pkg: PackageSummary | undefined;
Projects: what a workspace builds and runs, first on every page163 member: boolean;
164}) {
A project is an app or a library: libraries show their package and how to ship a release, not production165 const library = project.kind === "library";
Projects: what a workspace builds and runs, first on every page166 const base = `/${project.workspace}/${project.slug}`;
167 const production = deploys?.production ?? null;
168 const latest = deploys?.latest ?? null;
169 const source = project.source.kind === "hosted" ? project.source : null;
170 return (
171 <li className="group relative flex flex-col rounded-2xl border border-line bg-surface p-5 transition-colors hover:border-line-strong">
172 <div className="flex items-start gap-3">
173 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">
174 {project.private ? <Lock size={15} /> : <Box size={15} />}
175 </span>
176 <div className="min-w-0 grow">
177 <Link to={base} prefetch="intent" className="font-medium after:absolute after:inset-0 hover:underline">
178 {project.name}
179 </Link>
180 {production ? (
181 <a
182 href={production.url}
183 className="relative z-10 mt-0.5 flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-accent"
184 >
185 {host(production.url)}
186 <ArrowUpRight size={11} className="shrink-0" />
187 </a>
A project is an app or a library: libraries show their package and how to ship a release, not production188 ) : library && pkg ? (
189 <Link
190 to={packagePath(pkg)}
191 className="relative z-10 mt-0.5 block truncate font-mono text-xs text-muted hover:text-accent"
192 >
193 {packageLine(pkg)}
194 </Link>
Projects: what a workspace builds and runs, first on every page195 ) : (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look196 <p className="mt-0.5 truncate text-xs text-faint">
A project is an app or a library: libraries show their package and how to ship a release, not production197 {project.description ?? (library ? "Not published yet" : deploys?.enabled ? "Not deployed yet" : "Deployments are off")}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look198 </p>
Projects: what a workspace builds and runs, first on every page199 )}
200 </div>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look201 {(project.private || project.archived) && (
202 <span className="flex shrink-0 gap-1.5">
203 {project.private && <Pill>private</Pill>}
204 {project.archived && <Pill>archived</Pill>}
205 </span>
206 )}
Projects: what a workspace builds and runs, first on every page207 </div>
208
A project is an app or a library: libraries show their package and how to ship a release, not production209 {project.description && (production || (library && pkg)) && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>}
Projects: what a workspace builds and runs, first on every page210
211 <div className="mt-auto pt-5">
212 {member && latest && (
213 <p className="mb-3 flex items-center gap-2 text-xs text-muted">
214 <StatusDot status={latest.status} />
215 <span className="truncate">
216 {latest.kind === "production" ? "Production" : `Preview of ${latest.branch}`}
217 </span>
218 <span className="ml-auto shrink-0 text-faint">
219 <TimeAgo at={latest.createdAt} />
220 </span>
221 </p>
222 )}
223 <div className="flex items-center gap-4 border-t border-line pt-3 text-xs text-muted">
224 {source && (
225 <span className="flex min-w-0 items-center gap-1.5 font-mono">
226 <GitBranch size={12} className="shrink-0 text-faint" />
227 <span className="truncate">{source.defaultBranch}</span>
228 </span>
229 )}
230 <span className="flex items-center gap-1">
231 <CircleDot size={12} className="text-faint" />
232 {open?.issues ?? 0}
233 </span>
234 <span className="flex items-center gap-1">
235 <GitPullRequest size={12} className="text-faint" />
236 {open?.pulls ?? 0}
237 </span>
238 {member && deploys?.enabled && (deploys.previews ?? 0) > 0 && (
239 <span className="ml-auto flex items-center gap-1 text-accent">
240 <Rocket size={12} />
241 {deploys.previews} {deploys.previews === 1 ? "preview" : "previews"}
242 </span>
243 )}
244 </div>
245 </div>
246 </li>
247 );
248}
249
Agents as a team: lifecycle, merge queue, billing and a new shell250export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look251 const { slug, role, projects, open, deploys, pulls, members, usage } = loaderData;
Agents as a team: lifecycle, merge queue, billing and a new shell252 const totals = Object.values(open).reduce(
Projects: what a workspace builds and runs, first on every page253 (sum, counts) => ({ issues: sum.issues + counts.issues, pulls: sum.pulls + counts.pulls }),
Agents as a team: lifecycle, merge queue, billing and a new shell254 { issues: 0, pulls: 0 },
255 );
Projects: what a workspace builds and runs, first on every page256 const liveApps = Object.values(deploys).filter((entry) => entry.production).length;
Agents as a team: lifecycle, merge queue, billing and a new shell257 return (
258 <div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
259 <div className="min-w-0 space-y-10">
Projects: what a workspace builds and runs, first on every page260 {projects.length === 0 && role ? (
Agents as a team: lifecycle, merge queue, billing and a new shell261 <GetStarted slug={slug} />
262 ) : (
263 <>
Projects: what a workspace builds and runs, first on every page264 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
265 <Stat value={projects.length} label={projects.length === 1 ? "Project" : "Projects"} />
266 <Stat value={liveApps} label="In production" />
Agents as a team: lifecycle, merge queue, billing and a new shell267 <Stat value={totals.issues} label="Open issues" />
268 <Stat value={totals.pulls} label="Pull requests in progress" />
269 </div>
270
Projects: what a workspace builds and runs, first on every page271 <section>
272 <div className="flex items-center justify-between">
273 <h2 className="text-sm font-medium text-muted">Projects</h2>
274 {role && (
275 <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
276 <Plus size={13} />
277 New project
278 </Link>
279 )}
280 </div>
281 <ul className="mt-3 grid gap-4 sm:grid-cols-2">
282 {projects.map((project) => (
283 <ProjectCard
284 key={project.id}
285 project={project}
286 open={open[project.id]}
287 deploys={deploys[project.slug]}
A project is an app or a library: libraries show their package and how to ship a release, not production288 pkg={loaderData.packageOf[project.id]}
Projects: what a workspace builds and runs, first on every page289 member={role != null}
290 />
291 ))}
292 </ul>
293 </section>
294
Agents as a team: lifecycle, merge queue, billing and a new shell295 {pulls.length > 0 && (
296 <section>
297 <h2 className="text-sm font-medium text-muted">In progress</h2>
298 <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
Projects: what a workspace builds and runs, first on every page299 {pulls.map(({ pull, slug: project, name }) => (
Agents as a team: lifecycle, merge queue, billing and a new shell300 <li key={pull.id}>
301 <Link
302 prefetch="intent"
Projects: what a workspace builds and runs, first on every page303 to={`/${slug}/${project}/pull/${pull.number}`}
Agents as a team: lifecycle, merge queue, billing and a new shell304 className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised"
305 >
306 <PullIcon status={pull.status} />
307 <span className="min-w-0 grow">
308 <span className="block truncate font-medium">{pull.title}</span>
Projects: what a workspace builds and runs, first on every page309 <span className="text-xs text-muted">
310 {name} <span className="font-mono">#{pull.number}</span>
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent311 {pull.issue != null && ` · for #${pull.issue}`} · {openedBy(pull).name}
Agents as a team: lifecycle, merge queue, billing and a new shell312 </span>
313 </span>
314 <span className="hidden shrink-0 text-xs text-muted sm:block">
315 {pull.status === "draft" ? "Being worked on" : "Ready for review"}
316 </span>
317 <span className="w-14 shrink-0 text-right text-xs text-faint">
318 <TimeAgo at={pull.updatedAt} />
319 </span>
320 </Link>
321 </li>
322 ))}
323 </ul>
324 </section>
325 )}
326 </>
327 )}
328 </div>
329
330 <aside className="space-y-6">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look331 {role && usage && <UsageCard slug={slug} glance={usage} owner={role === "owner"} />}
332
Agents as a team: lifecycle, merge queue, billing and a new shell333 {members ? (
334 <section>
335 <div className="flex items-center justify-between">
Projects: what a workspace builds and runs, first on every page336 <h2 className="text-sm font-medium">Members</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell337 <Link to={`/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
338 {role === "owner" ? "Manage" : "See all"}
339 </Link>
340 </div>
341 <ul className="mt-3 space-y-1.5">
342 {members.slice(0, MAX_FACES).map((member) => (
343 <li key={member.username} className="flex items-center gap-2 text-sm">
344 <Avatar name={member.username} />
345 <span className="grow truncate font-mono">{member.username}</span>
346 {member.role === "owner" && <Pill>owner</Pill>}
347 </li>
348 ))}
349 </ul>
Projects: what a workspace builds and runs, first on every page350 {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
Agents as a team: lifecycle, merge queue, billing and a new shell351 </section>
352 ) : (
353 <p className="text-sm text-muted">
Projects: what a workspace builds and runs, first on every page354 Members of this workspace build its projects. Anyone can open an issue or a pull request on a public one.
Agents as a team: lifecycle, merge queue, billing and a new shell355 </p>
356 )}
357
358 {role && (
359 <section className="rounded-xl border border-line bg-surface p-5">
360 <h2 className="flex items-center gap-2 font-medium">
Projects: what a workspace builds and runs, first on every page361 <Rocket size={15} className="text-faint" />
362 Deploy on g1t.page
363 </h2>
364 <p className="mt-1.5 text-sm text-muted">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look365 Off until you turn them on for a project, under its Settings. Then production builds from its default
366 branch, with a live preview for every pull request. Apps cost nothing while no one visits.
Projects: what a workspace builds and runs, first on every page367 </p>
368 <Link
369 prefetch="intent"
370 to={`/${slug}/-/billing`}
371 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
372 >
373 Billing and plans <ArrowRight size={13} />
374 </Link>
375 </section>
376 )}
377
378 {role && (
379 <section className="rounded-xl border border-line bg-surface p-5">
380 <h2 className="flex items-center gap-2 font-medium">
Agents as a team: lifecycle, merge queue, billing and a new shell381 <KeyRound size={15} className="text-faint" />
382 Automate without a service account
383 </h2>
384 <p className="mt-1.5 text-sm text-muted">
Projects: what a workspace builds and runs, first on every page385 A workspace has access tokens of its own for CI, integrations and agents. They act as the workspace,
386 not as a person.
Agents as a team: lifecycle, merge queue, billing and a new shell387 </p>
388 <Link
389 prefetch="intent"
390 to={`/${slug}/-/tokens`}
391 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
392 >
393 Access tokens <ArrowRight size={13} />
394 </Link>
395 </section>
396 )}
397 </aside>
398 </div>
399 );
400}