Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

201 linesCodeBlameRaw
1import { ArrowRight, Box, ShieldCheck, Users } from "lucide-react";
2import { type ReactNode, Suspense } from "react";
3import { Await, Link, data } from "react-router";
4
5import { type User, hasCodeAccess } from "@g1t/contracts";
6
7import type { Route } from "./+types/workspace-overview";
8
9import { UsageCard } from "../../components/usage-card";
10import { billing, identity } from "../../lib/services.server";
11import { loadOnboarding } from "../../lib/onboarding.server";
12import { OnboardingOnPage } from "../../components/onboarding";
13import { getViewer, roleIn } from "../../lib/session.server";
14import { usageFor } from "../../lib/workspace-usage.server";
15import { workspaceProjects } from "../../lib/workspace-projects.server";
16import { page } from "../../lib/meta";
17import { Avatar } from "../../components/ui/avatar";
18import { Badge } from "../../components/ui/badge";
19import { Card } from "../../components/ui/card";
20import { Unavailable } from "../../components/boundary";
21import { GroundSkeleton, GroundTruth } from "../../components/ground-truth";
22import { isFailed, part } from "../../lib/partial";
23import { loadRepos } from "../../lib/home.server";
24import { shapeGround } from "../../lib/ground";
25import { providerQuarter, signalsRange } from "../../lib/signals";
26import { loadGroups, loadSignalData } from "../../lib/signals.server";
27
28export function meta({ params, ...args }: Route.MetaArgs) {
29 return page(args, { title: `Workspace · ${params.owner} · g1t` });
30}
31
32/** Most members listed before "and N more". */
33const MAX_FACES = 10;
34
35/**
36 * The workspace's ground truth, from what its work says (lib/signals.ts),
37 * for members who see Code: the past week, the quarter's agent spend
38 * threaded to the work it produced, what is moving and what is waiting
39 * team by team, and quiet changes in who works where. Streamed, each part
40 * on its own. Then the workspace at a glance, for every member: who is in
41 * it, its projects, and the month's usage against its plan. Each part is
42 * left out when its service does not answer.
43 */
44export async function loader({ params, context, request }: Route.LoaderArgs) {
45 const viewer = getViewer(context);
46 const slug = params.owner.toLowerCase();
47 const role = roleIn(viewer, slug);
48 if (!role) throw data(null, { status: 404 });
49 // Getting started, for an owner, as on Home: streamed beside the rest.
50 const setupAsked = new URL(request.url).searchParams.has("setup");
51 const onboarding = loadOnboarding(viewer, slug, !setupAsked).catch(() => null);
52 const membership = viewer?.workspaces?.find((m) => m.slug === slug) ?? null;
53 const ground = viewer && hasCodeAccess(membership) ? loadGround(viewer, slug, Date.now()).catch(() => null) : null;
54 const [members, usage, projects] = await Promise.all([
55 part(identity.listMembers(slug, viewer), "The member list"),
56 usageFor(slug, viewer),
57 workspaceProjects(slug, viewer).catch(() => null),
58 ]);
59 return {
60 slug,
61 role,
62 onboarding,
63 setupAsked,
64 members: isFailed(members) ? members : members.ok ? members.value : null,
65 usage,
66 projects: projects?.ok ? projects.value.length : null,
67 ground,
68 };
69}
70
71/** Everything the work says, shaped for the page; null when the work service did not answer. */
72async function loadGround(viewer: User, slug: string, now: number) {
73 const owned = await loadRepos(viewer, slug).catch(() => null);
74 if (!owned) return null;
75 const [found, groups, providers] = await Promise.all([
76 loadSignalData(viewer, slug, now, owned).catch(() => null),
77 loadGroups(viewer, slug, owned).catch(() => null),
78 // What the connected providers say was spent: beside the rest, never in the way of it.
79 billing
80 .providerCosts(slug, viewer, signalsRange(now).quarter)
81 .then((result) => (result.ok ? result.value : null))
82 .catch(() => null),
83 ]);
84 return found ? { ...shapeGround(found, groups, now), providers: providerQuarter(providers, found.range.quarter) } : null;
85}
86
87function Tile({ icon, label, value, to, action }: { icon: ReactNode; label: string; value: string; to: string; action: string }) {
88 return (
89 <Link to={to} className="group rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
90 <p className="flex items-center gap-1.5 text-xs text-muted">
91 {icon}
92 {label}
93 </p>
94 <p className="mt-1.5 text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
95 <p className="mt-0.5 flex items-center gap-1 text-xs text-faint group-hover:text-muted">
96 {action} <ArrowRight size={11} />
97 </p>
98 </Link>
99 );
100}
101
102export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
103 const { slug, role, members, usage, projects } = loaderData;
104 return (
105 <div className="space-y-8">
106 <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} />
107 {loaderData.ground && (
108 <Suspense fallback={<GroundSkeleton />}>
109 <Await resolve={loaderData.ground}>
110 {(ground) => (ground ? <GroundTruth slug={slug} ground={ground} /> : <Unavailable what="What the work says" />)}
111 </Await>
112 </Suspense>
113 )}
114 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
115 <Tile
116 icon={<Users size={12} />}
117 label="Members"
118 value={members && !isFailed(members) ? String(members.length) : "—"}
119 to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`}
120 action={role === "owner" ? "Manage people" : "See everyone"}
121 />
122 <Tile icon={<Box size={12} />} label="Projects" value={projects == null ? "—" : String(projects)} to={`/${slug}/-/projects`} action="Open Code" />
123 </div>
124 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
125 <div className="space-y-6">
126 {usage ? (
127 <UsageCard slug={slug} glance={usage} owner={role === "owner"} />
128 ) : (
129 <Card asChild className="p-5">
130 <section>
131 <h2 className="font-medium">Usage</h2>
132 <p className="mt-1.5 text-sm text-muted">This month&apos;s usage isn&apos;t available right now.</p>
133 <Link to={`/${slug}/-/usage`} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
134 Open Usage <ArrowRight size={13} />
135 </Link>
136 </section>
137 </Card>
138 )}
139 <Card asChild className="p-5">
140 <section>
141 <h2 className="flex items-center gap-2 font-medium">
142 <ShieldCheck size={15} className="text-faint" />
143 Policies
144 </h2>
145 <p className="mt-1.5 text-sm text-muted">
146 How long runs may take, the rules every repository follows, and how the workspace&apos;s security is set up.
147 </p>
148 <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm">
149 <Link to={`/${slug}/-/guardrails`} className="text-accent hover:underline">
150 Guardrails
151 </Link>
152 <Link to={`/${slug}/-/rules`} className="text-accent hover:underline">
153 Rules
154 </Link>
155 <Link to={`/${slug}/-/security/settings`} className="text-accent hover:underline">
156 Security settings
157 </Link>
158 <Link to={`/${slug}/-/audit`} className="text-accent hover:underline">
159 Audit log
160 </Link>
161 </div>
162 </section>
163 </Card>
164 </div>
165 <Card asChild className="self-start p-5">
166 <section>
167 <div className="flex items-center justify-between">
168 <h2 className="font-medium">Members</h2>
169 <Link to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
170 {role === "owner" ? "Manage" : "See all"}
171 </Link>
172 </div>
173 {isFailed(members) ? (
174 <div className="mt-3">
175 <Unavailable what={members.what} compact />
176 </div>
177 ) : members ? (
178 <>
179 <ul className="mt-3 space-y-2">
180 {members.slice(0, MAX_FACES).map((member) => (
181 <li key={member.username} className="flex items-center gap-2.5 text-sm">
182 <Avatar name={member.username} image={member.avatar} size={24} />
183 <Link to={`/u/${member.username}`} className="min-w-0 grow truncate hover:text-accent">
184 {member.name?.trim() || member.username}
185 {member.name?.trim() && <span className="ml-1.5 font-mono text-xs text-faint">{member.username}</span>}
186 </Link>
187 {member.role === "owner" && <Badge size="md">owner</Badge>}
188 </li>
189 ))}
190 </ul>
191 {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
192 </>
193 ) : (
194 <p className="mt-3 text-sm text-muted">The member list isn&apos;t available right now.</p>
195 )}
196 </section>
197 </Card>
198 </div>
199 </div>
200 );
201}