| 1 | import { ArrowRight, Bot, Box, ShieldCheck, Users } from "lucide-react"; |
| 2 | import { type ReactNode, Suspense } from "react"; |
| 3 | import { Await, Link, data } from "react-router"; |
| 4 | |
| 5 | import { type User, hasCodeAccess } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/workspace-overview"; |
| 8 | |
| 9 | import { UsageCard } from "../../components/usage-card"; |
| 10 | import { identity } from "../../lib/services.server"; |
| 11 | import { loadOnboarding } from "../../lib/onboarding.server"; |
| 12 | import { OnboardingOnPage } from "../../components/onboarding"; |
| 13 | import { getViewer, roleIn } from "../../lib/session.server"; |
| 14 | import { usageFor } from "../../lib/workspace-usage.server"; |
| 15 | import { workspaceProjects } from "../../lib/workspace-projects.server"; |
| 16 | import { page } from "../../lib/meta"; |
| 17 | import { Avatar } from "../../components/ui/avatar"; |
| 18 | import { Badge } from "../../components/ui/badge"; |
| 19 | import { Card } from "../../components/ui/card"; |
| 20 | import { Skeleton } from "../../components/ui/skeleton"; |
| 21 | import { Boundary, Unavailable } from "../../components/boundary"; |
| 22 | import { Rhythm } from "../../components/rhythm"; |
| 23 | import { PulseSkeleton, PulseStrip } from "../../components/signals"; |
| 24 | import { SpendFlow } from "../../components/spend-flow"; |
| 25 | import { isFailed, part } from "../../lib/partial"; |
| 26 | import { loadRepos } from "../../lib/home.server"; |
| 27 | import { dailySignal, dayList, dollars, newGround, newHabits, rhythm, silos, spendFlow, spoken, waitingRows } from "../../lib/signals"; |
| 28 | import { type SignalData, loadGroups, loadSignalData } from "../../lib/signals.server"; |
| 29 | |
| 30 | export 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". */ |
| 35 | const MAX_FACES = 10; |
| 36 | /** Entries each quiet change lists. */ |
| 37 | const 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 | */ |
| 48 | export 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. */ |
| 76 | async 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 | |
| 83 | function 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 | |
| 114 | type Ground = ReturnType<typeof shapeGround>; |
| 115 | |
| 116 | function 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 | |
| 131 | export 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's usage isn'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'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't available right now.</p> |
| 216 | )} |
| 217 | </section> |
| 218 | </Card> |
| 219 | </div> |
| 220 | </div> |
| 221 | ); |
| 222 | } |
| 223 | |
| 224 | function 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. */ |
| 234 | function 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 | |
| 247 | type QuietRow = Ground["silos"][number]; |
| 248 | |
| 249 | function 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 | |
| 277 | function 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'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 | } |