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.

351 linesCodeBlameRaw
1import { ArrowRight, Bot, 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 { 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 { Skeleton } from "../../components/ui/skeleton";
21import { Boundary, Unavailable } from "../../components/boundary";
22import { Rhythm } from "../../components/rhythm";
23import { PulseSkeleton, PulseStrip } from "../../components/signals";
24import { SpendFlow } from "../../components/spend-flow";
25import { isFailed, part } from "../../lib/partial";
26import { loadRepos } from "../../lib/home.server";
27import { dailySignal, dayList, dollars, newGround, newHabits, rhythm, silos, spendFlow, spoken, waitingRows } from "../../lib/signals";
28import { type SignalData, loadGroups, loadSignalData } from "../../lib/signals.server";
29
30export function meta({ params, ...args }: Route.MetaArgs) {
31 return page(args, { title: `Workspace · ${params.owner} · g1t` });
32}
33
34/** Most members listed before "and N more". */
35const MAX_FACES = 10;
36/** Entries each quiet change lists. */
37const QUIET_ROWS = 4;
38
39/**
40 * The workspace's ground truth, from what its work says (lib/signals.ts),
41 * for members who see Code: the past week, the quarter's agent spend
42 * threaded to the work it produced, what is moving and what is waiting
43 * team by team, and quiet changes in who works where. Streamed, each part
44 * on its own. Then the workspace at a glance, for every member: who is in
45 * it, its projects, and the month's usage against its plan. Each part is
46 * left out when its service does not answer.
47 */
48export async function loader({ params, context, request }: Route.LoaderArgs) {
49 const viewer = getViewer(context);
50 const slug = params.owner.toLowerCase();
51 const role = roleIn(viewer, slug);
52 if (!role) throw data(null, { status: 404 });
53 // Getting started, for an owner, as on Home: streamed beside the rest.
54 const setupAsked = new URL(request.url).searchParams.has("setup");
55 const onboarding = loadOnboarding(viewer, slug, !setupAsked).catch(() => null);
56 const membership = viewer?.workspaces?.find((m) => m.slug === slug) ?? null;
57 const ground = viewer && hasCodeAccess(membership) ? loadGround(viewer, slug, Date.now()).catch(() => null) : null;
58 const [members, usage, projects] = await Promise.all([
59 part(identity.listMembers(slug, viewer), "The member list"),
60 usageFor(slug, viewer),
61 workspaceProjects(slug, viewer).catch(() => null),
62 ]);
63 return {
64 slug,
65 role,
66 onboarding,
67 setupAsked,
68 members: isFailed(members) ? members : members.ok ? members.value : null,
69 usage,
70 projects: projects?.ok ? projects.value.length : null,
71 ground,
72 };
73}
74
75/** Everything the work says, shaped for the page; null when the work service did not answer. */
76async function loadGround(viewer: User, slug: string, now: number) {
77 const owned = await loadRepos(viewer, slug).catch(() => null);
78 if (!owned) return null;
79 const [found, groups] = await Promise.all([loadSignalData(viewer, slug, now, owned).catch(() => null), loadGroups(viewer, slug, owned).catch(() => null)]);
80 return found ? shapeGround(found, groups, now) : null;
81}
82
83function shapeGround(found: SignalData, groups: Awaited<ReturnType<typeof loadGroups>>, now: number) {
84 const name = (id: string) => found.repos[id]?.name ?? "a project";
85 const path = (id: string) => (found.repos[id] ? `/${found.repos[id]!.namespace}/${found.repos[id]!.name}` : null);
86 const quarterSpend = found.quarterUsage ? found.quarterUsage.byPull.reduce((sum, slice) => sum + slice.micros, 0) : null;
87 return {
88 tiles: dailySignal(found).tiles,
89 complete: found.complete,
90 capped: found.signals.pullsCapped,
91 flow: spendFlow({ slug: found.slug, repos: found.repos, signals: found.signals, quarterUsage: found.quarterUsage, quarter: found.range.quarter }),
92 sized: (quarterSpend ?? 0) > 0 ? ("spend" as const) : ("pulls" as const),
93 quarterSpend,
94 rhythm: groups ? { by: groups.by, rows: rhythm(found.signals.days, dayList(found.range.until, found.range.days), groups.groups) } : null,
95 waiting: waitingRows(found.signals.waiting, found.repos).map((pull) => ({ ...pull, age: spoken(Math.max(0, (now - Date.parse(pull.createdAt)) / 1000)) })),
96 silos: silos(found.signals.authors, now)
97 .slice(0, QUIET_ROWS)
98 .map((silo) => ({
99 key: silo.repoId,
100 to: path(silo.repoId),
101 where: name(silo.repoId),
102 line: `${silo.name} merged ${silo.theirs} of its ${silo.recent} changes in 90 days`,
103 more: silo.joining.length > 0 ? `${silo.joining.map((who) => who.name).join(", ")} started this month` : "No one else has started there",
104 })),
105 newGround: newGround(found.signals.authors, now)
106 .slice(0, QUIET_ROWS)
107 .map((row) => ({ key: `${row.ownerId}:${row.repoId}`, to: path(row.repoId), where: name(row.repoId), line: `${row.name} merged a first change here`, more: `${row.elsewhere} merges elsewhere first` })),
108 habits: newHabits(found.signals.pulls, now)
109 .slice(0, QUIET_ROWS)
110 .map((row) => ({ key: row.ownerId, to: path(row.repoId), where: name(row.repoId), line: `${row.name} merged a first agent-made change`, more: `after ${row.byHand} by hand` })),
111 };
112}
113
114type Ground = ReturnType<typeof shapeGround>;
115
116function Tile({ icon, label, value, to, action }: { icon: ReactNode; label: string; value: string; to: string; action: string }) {
117 return (
118 <Link to={to} className="group rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
119 <p className="flex items-center gap-1.5 text-xs text-muted">
120 {icon}
121 {label}
122 </p>
123 <p className="mt-1.5 text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
124 <p className="mt-0.5 flex items-center gap-1 text-xs text-faint group-hover:text-muted">
125 {action} <ArrowRight size={11} />
126 </p>
127 </Link>
128 );
129}
130
131export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
132 const { slug, role, members, usage, projects } = loaderData;
133 return (
134 <div className="space-y-8">
135 <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} />
136 {loaderData.ground && (
137 <Suspense fallback={<GroundSkeleton />}>
138 <Await resolve={loaderData.ground}>{(ground) => (ground ? <GroundTruth slug={slug} ground={ground} /> : <Unavailable what="What the work says" />)}</Await>
139 </Suspense>
140 )}
141 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
142 <Tile icon={<Users size={12} />} label="Members" value={members && !isFailed(members) ? String(members.length) : "—"} to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} action={role === "owner" ? "Manage people" : "See everyone"} />
143 <Tile icon={<Box size={12} />} label="Projects" value={projects == null ? "—" : String(projects)} to={`/${slug}/-/projects`} action="Open Code" />
144 </div>
145 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
146 <div className="space-y-6">
147 {usage ? (
148 <UsageCard slug={slug} glance={usage} owner={role === "owner"} />
149 ) : (
150 <Card asChild className="p-5">
151 <section>
152 <h2 className="font-medium">Usage</h2>
153 <p className="mt-1.5 text-sm text-muted">This month&apos;s usage isn&apos;t available right now.</p>
154 <Link to={`/${slug}/-/usage`} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
155 Open Usage <ArrowRight size={13} />
156 </Link>
157 </section>
158 </Card>
159 )}
160 <Card asChild className="p-5">
161 <section>
162 <h2 className="flex items-center gap-2 font-medium">
163 <ShieldCheck size={15} className="text-faint" />
164 Policies
165 </h2>
166 <p className="mt-1.5 text-sm text-muted">
167 How long runs may take, the rules every repository follows, and how the workspace&apos;s security is set up.
168 </p>
169 <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm">
170 <Link to={`/${slug}/-/guardrails`} className="text-accent hover:underline">
171 Guardrails
172 </Link>
173 <Link to={`/${slug}/-/rules`} className="text-accent hover:underline">
174 Rules
175 </Link>
176 <Link to={`/${slug}/-/security/settings`} className="text-accent hover:underline">
177 Security settings
178 </Link>
179 <Link to={`/${slug}/-/audit`} className="text-accent hover:underline">
180 Audit log
181 </Link>
182 </div>
183 </section>
184 </Card>
185 </div>
186 <Card asChild className="self-start p-5">
187 <section>
188 <div className="flex items-center justify-between">
189 <h2 className="font-medium">Members</h2>
190 <Link to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
191 {role === "owner" ? "Manage" : "See all"}
192 </Link>
193 </div>
194 {isFailed(members) ? (
195 <div className="mt-3">
196 <Unavailable what={members.what} compact />
197 </div>
198 ) : members ? (
199 <>
200 <ul className="mt-3 space-y-2">
201 {members.slice(0, MAX_FACES).map((member) => (
202 <li key={member.username} className="flex items-center gap-2.5 text-sm">
203 <Avatar name={member.username} image={member.avatar} size={24} />
204 <Link to={`/u/${member.username}`} className="min-w-0 grow truncate hover:text-accent">
205 {member.name?.trim() || member.username}
206 {member.name?.trim() && <span className="ml-1.5 font-mono text-xs text-faint">{member.username}</span>}
207 </Link>
208 {member.role === "owner" && <Badge size="md">owner</Badge>}
209 </li>
210 ))}
211 </ul>
212 {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
213 </>
214 ) : (
215 <p className="mt-3 text-sm text-muted">The member list isn&apos;t available right now.</p>
216 )}
217 </section>
218 </Card>
219 </div>
220 </div>
221 );
222}
223
224function GroundSkeleton() {
225 return (
226 <div className="space-y-6">
227 <PulseSkeleton />
228 <Skeleton className="h-112 w-full rounded-2xl" />
229 </div>
230 );
231}
232
233/** A part of the ground truth: a mono eyebrow, a sentence for a heading, and what it shows. */
234function Section({ id, eyebrow, title, hint, children }: { id: string; eyebrow: string; title: string; hint?: ReactNode; children: ReactNode }) {
235 return (
236 <section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-6 rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-7">
237 <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">{eyebrow}</p>
238 <h2 id={`${id}-title`} className="mt-2 font-display text-2xl leading-tight tracking-tight text-fg">
239 {title}
240 </h2>
241 {hint && <p className="mt-1.5 max-w-3xl text-sm text-muted">{hint}</p>}
242 <div className="mt-6">{children}</div>
243 </section>
244 );
245}
246
247type QuietRow = Ground["silos"][number];
248
249function QuietList({ title, hint, rows, empty }: { title: string; hint: string; rows: QuietRow[]; empty: string }) {
250 return (
251 <div className="min-w-0">
252 <h3 className="text-sm font-semibold text-fg">{title}</h3>
253 <p className="mt-0.5 text-xs text-muted">{hint}</p>
254 {rows.length === 0 ? (
255 <p className="mt-3 text-sm text-faint">{empty}</p>
256 ) : (
257 <ul className="mt-3 space-y-3">
258 {rows.map((row) => (
259 <li key={row.key} className="text-sm">
260 {row.to ? (
261 <Link to={row.to} className="font-medium text-fg hover:text-accent">
262 {row.where}
263 </Link>
264 ) : (
265 <span className="font-medium text-fg">{row.where}</span>
266 )}
267 <p className="text-fg-soft">{row.line}</p>
268 <p className="font-mono text-[0.6875rem] text-faint">{row.more}</p>
269 </li>
270 ))}
271 </ul>
272 )}
273 </div>
274 );
275}
276
277function GroundTruth({ slug, ground }: { slug: string; ground: Ground }) {
278 return (
279 <div className="space-y-6">
280 <Boundary what="The past week">
281 <PulseStrip tiles={ground.tiles} />
282 </Boundary>
283 {!ground.complete && <p className="text-xs text-faint">Some projects didn&apos;t answer, so what follows leaves them out.</p>}
284 <Section
285 id="spend"
286 eyebrow="Spend, threaded to the work"
287 title={ground.quarterSpend ? `${dollars(ground.quarterSpend)} of agent spend this quarter, and what it went into` : "This quarter's work, and how much of it agents did"}
288 hint={
289 ground.sized === "spend"
290 ? "Each milestone, its work items, their change set in each project and its pull requests, sized by what the agents' usage on them came to and shaded by how much was an agent's. Choose a box to open what is under it."
291 : "Usage isn't known for this quarter, so each box is sized by its pull requests and shaded by how many were an agent's. Choose a box to open what is under it."
292 }
293 >
294 <Boundary what="Spend, threaded to the work">
295 <SpendFlow flow={ground.flow} sized={ground.sized} />
296 </Boundary>
297 {ground.capped && <p className="mt-3 text-xs text-faint">There were more pull requests this quarter than this reads, so the newest are shown.</p>}
298 </Section>
299 <Section
300 id="rhythm"
301 eyebrow="What's moving and what's waiting"
302 title={ground.rhythm?.by === "project" ? "Each project's last two weeks" : "Each team's last two weeks"}
303 hint="Pull requests and agent sessions above the line; reviews, comments and requests for changes below it. Each row is drawn to its own scale: point at a day for its numbers."
304 >
305 <div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_18rem]">
306 <Boundary what="What's moving">{ground.rhythm ? <Rhythm rows={ground.rhythm.rows} by={ground.rhythm.by} /> : <Unavailable what="The teams" />}</Boundary>
307 <div className="min-w-0">
308 <h3 className="text-sm font-semibold text-fg">Waiting on a first review</h3>
309 <p className="mt-0.5 text-xs text-muted">Open, and no one has reviewed them yet: the oldest first.</p>
310 {ground.waiting.length === 0 ? (
311 <p className="mt-3 text-sm text-faint">Nothing is waiting for a first review.</p>
312 ) : (
313 <ul className="mt-3 divide-y divide-line">
314 {ground.waiting.map((pull) => (
315 <li key={`${pull.repoId}#${pull.number}`} className="py-2.5 text-sm">
316 <Link to={pull.to} className="flex items-start gap-2 hover:text-accent">
317 <span className="shrink-0 pt-px font-mono text-xs text-muted">#{pull.number}</span>
318 <span className="min-w-0 grow">
319 <span className="line-clamp-2 font-medium text-fg">{pull.title}</span>
320 <span className="font-mono text-[0.6875rem] text-faint">
321 {pull.repo.name} · open {pull.age}
322 {pull.blocks.length > 0 && ` · holds up ${pull.blocks.length}`}
323 </span>
324 </span>
325 {pull.agent && <Bot size={13} className="mt-0.5 shrink-0 text-accent" aria-label="An agent's" />}
326 </Link>
327 </li>
328 ))}
329 </ul>
330 )}
331 <Link to={`/${slug}/-/overview?tab=needs#work`} className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
332 The review queue <ArrowRight size={11} />
333 </Link>
334 </div>
335 </div>
336 </Section>
337 <Section
338 id="quiet"
339 eyebrow="Quiet changes"
340 title="Who works where, and what is spreading"
341 hint="What no roll-up shows: knowledge resting with one person, people moving into new ground, and new ways of working taking hold. Each is a place to look, not a verdict."
342 >
343 <div className="grid gap-8 md:grid-cols-3">
344 <QuietList title="Knowledge silos" hint="Projects where one person has done most of the last 90 days' work" rows={ground.silos} empty="No project rests on one person right now." />
345 <QuietList title="New ground" hint="People who merged to a project for the first time in the last two weeks" rows={ground.newGround} empty="No one has moved into a new project in the last two weeks." />
346 <QuietList title="New habits" hint="People whose first agent-made change merged in the last two weeks" rows={ground.habits} empty="No one new started merging agents' work in the last two weeks." />
347 </div>
348 </Section>
349 </div>
350 );
351}