flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/apps/web/app/components/shell.tsx

1,147 lines45,317 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.

Agents as a team: lifecycle, merge queue, billing and a new shell1import {
Observability gets its own page; tabs never overflow2 Activity,
A catalogue of model providers, and settings that feel like settings3 ArrowLeft,
A flat project menu with Code first, views as tabs, and Stripe for every owner4 GanttChart,
5 KanbanSquare,
6 Package,
7 Sparkles,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request8 BarChart3,
Agents as a team: lifecycle, merge queue, billing and a new shell9 BookMarked,
10 BookOpen,
11 Check,
12 ChevronsUpDown,
A Vercel-like sidebar and a new mark13 Ellipsis,
Agents as a team: lifecycle, merge queue, billing and a new shell14 CircleDot,
15 Code2,
16 Compass,
17 CornerDownLeft,
18 CreditCard,
19 GitPullRequest,
20 History,
Secrets and variables: one list, rows per environment, for workflows and deployments21 Fingerprint,
Agents as a team: lifecycle, merge queue, billing and a new shell22 KeyRound,
Projects: what a workspace builds and runs, first on every page23 Box,
Agents as a team: lifecycle, merge queue, billing and a new shell24 LayoutDashboard,
Projects: what a workspace builds and runs, first on every page25 LayoutGrid,
Agents as a team: lifecycle, merge queue, billing and a new shell26 ListTree,
27 Lock,
28 LogOut,
29 Menu,
30 Plus,
31 Search,
Integrations: your own model provider, alerts that open issues, tickets agents read32 Plug,
Agents as a team: lifecycle, merge queue, billing and a new shell33 Settings,
34 Users,
Webhooks: every event, to your own addresses, signed and retried35 Webhook,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs36 PlayCircle,
Pull requests say where they are live; the project menu shows what is coming37 Bot,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains38 Brain,
Pull requests say where they are live; the project menu shows what is coming39 ShieldCheck,
Deployments: a preview for every pull request, production on g1t.page40 Rocket,
Agents as a team: lifecycle, merge queue, billing and a new shell41 X,
42} from "lucide-react";
43import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
44import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
45
Workspace names and icons, and a component kit for every control46import type { Membership, User } from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell47import { MICROS_PER_DOLLAR } from "@g1t/contracts";
48
A Vercel-like sidebar and a new mark49import { Mark } from "./logo";
Agents as a team: lifecycle, merge queue, billing and a new shell50import { Avatar } from "./ui";
51import {
52 DropdownMenu,
53 DropdownMenuContent,
54 DropdownMenuItem,
55 DropdownMenuLabel,
56 DropdownMenuSeparator,
57 DropdownMenuTrigger,
58} from "./ui/dropdown-menu";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales59import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
Agents as a team: lifecycle, merge queue, billing and a new shell60
61/** What the sidebar needs, worked out by the root loader for a signed-in person. */
62export type ShellData = {
63 /** The workspace the sidebar is about: the one being looked at, or their first. */
Workspace names and icons, and a component kit for every control64 workspace: Membership | null;
Projects: what a workspace builds and runs, first on every page65 /** Its projects, by name: `name` is the slug in their address. */
66 repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
67 /** The project being looked at, if any, whoever owns it. */
Agents as a team: lifecycle, merge queue, billing and a new shell68 repo: {
69 namespace: string;
70 name: string;
71 member: boolean;
72 issues: number;
73 pulls: number;
74 } | null;
Billing accounts, terms and enterprises; g1t is no longer free75 /** Where the workspace stands against its usage limit, if billing is on. */
76 limit: {
77 exposureMicros: number;
78 ceilingMicros: number | null;
79 state: "ok" | "warning" | "stopped";
80 comped: boolean;
81 } | null;
Free while g1t is being built out; agents can check out their own forks82 /** Whether g1t charges nothing for now, while it is being built out. */
83 free?: boolean;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request84 /** What its agents have cost since the start of the month. */
Usage while free is shown at cost; agents get rustfmt and clippy85 /** This month's usage: charged, or at cost while g1t is free. */
86 monthUsageMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell87};
88
89function SidebarLink({
90 to,
91 icon,
92 end,
93 count,
94 also,
95 children,
96}: {
97 to: string;
98 icon: ReactNode;
99 end?: boolean;
100 count?: number;
Projects: what a workspace builds and runs, first on every page101 /** Other path prefixes under which this link is the current one. */
102 also?: string | string[];
Agents as a team: lifecycle, merge queue, billing and a new shell103 children: ReactNode;
104}) {
105 const { pathname } = useLocation();
106 return (
107 <NavLink
108 to={to}
109 end={end}
110 prefetch="intent"
111 className={({ isActive, isPending }) => {
Projects: what a workspace builds and runs, first on every page112 const current =
113 isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
A Vercel-like sidebar and a new mark114 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
Agents as a team: lifecycle, merge queue, billing and a new shell115 current
116 ? "bg-raised font-medium text-fg"
117 : isPending
118 ? "bg-raised/60 text-fg"
119 : "text-muted hover:bg-raised/60 hover:text-fg"
120 }`;
121 }}
122 >
123 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
124 <span className="min-w-0 grow truncate">{children}</span>
125 {count != null && count > 0 && (
126 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
127 {count}
128 </span>
129 )}
130 </NavLink>
131 );
132}
133
Pull requests say where they are live; the project menu shows what is coming134/**
135 * Something a project will have, shown where it will live so people can
136 * see where g1t is going. Not a link: nothing is behind it yet.
137 */
138function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
139 return (
140 <div
141 title={about}
142 aria-disabled="true"
143 className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint"
144 >
145 <span className="shrink-0 opacity-70">{icon}</span>
146 <span className="grow truncate">{children}</span>
147 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
148 Soon
149 </span>
150 </div>
151 );
152}
153
The project menu opens by section, and the workspace has its own panel154/**
A flat project menu with Code first, views as tabs, and Stripe for every owner155 * A page that is coming, as a link to its roadmap page: faint, with Soon,
156 * and marked current on any of its section's Soon pages.
The project menu opens by section, and the workspace has its own panel157 */
A flat project menu with Code first, views as tabs, and Stripe for every owner158function SidebarSoonLink({
The project menu opens by section, and the workspace has its own panel159 to,
160 also,
A flat project menu with Code first, views as tabs, and Stripe for every owner161 icon,
162 about,
The project menu opens by section, and the workspace has its own panel163 children,
164}: {
165 to: string;
A flat project menu with Code first, views as tabs, and Stripe for every owner166 also?: string[];
167 icon: ReactNode;
168 about: string;
The project menu opens by section, and the workspace has its own panel169 children: ReactNode;
170}) {
171 const { pathname } = useLocation();
172 return (
173 <NavLink
174 to={to}
A flat project menu with Code first, views as tabs, and Stripe for every owner175 title={about}
The project menu opens by section, and the workspace has its own panel176 prefetch="intent"
A flat project menu with Code first, views as tabs, and Stripe for every owner177 className={({ isActive }) => {
178 const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
179 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
180 current ? "bg-raised font-medium text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"
The project menu opens by section, and the workspace has its own panel181 }`;
182 }}
183 >
A flat project menu with Code first, views as tabs, and Stripe for every owner184 <span className="shrink-0 opacity-80">{icon}</span>
The project menu opens by section, and the workspace has its own panel185 <span className="min-w-0 grow truncate">{children}</span>
186 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
187 Soon
188 </span>
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales189 </NavLink>
The project menu opens by section, and the workspace has its own panel190 );
191}
192
A flat project menu with Code first, views as tabs, and Stripe for every owner193/** Icons for what spans projects. */
194const WORKSPACE_ICONS: Record<string, ReactNode> = {
195 board: <KanbanSquare size={15} />,
196 roadmap: <GanttChart size={15} />,
197 packages: <Package size={15} />,
198 fleet: <Bot size={15} />,
199};
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales200
A flat project menu with Code first, views as tabs, and Stripe for every owner201/** The Soon pages of a section, as paths, so its link is current on them. */
202function soonPaths(base: string, section: RoadmapItem["section"]): string[] {
203 return roadmapIn(section).map((item) => `${base}/soon/${item.key}`);
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales204}
205
Agents as a team: lifecycle, merge queue, billing and a new shell206function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
207 return (
A Vercel-like sidebar and a new mark208 <section className="mt-6">
209 <div className="mb-1 flex h-6 items-center justify-between px-2">
210 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell211 {action}
212 </div>
213 <div className="space-y-px">{children}</div>
214 </section>
215 );
216}
217
Workspace names and icons, and a component kit for every control218/** What a workspace is called where people read it: its display name, else its slug. */
219function displayName(membership: Membership): string {
220 return membership.name?.trim() || membership.slug;
221}
222
Agents as a team: lifecycle, merge queue, billing and a new shell223function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
Workspace names and icons, and a component kit for every control224 const workspace = shell.workspace;
225 const current = workspace?.slug;
226 const label = workspace ? displayName(workspace) : "Choose a workspace";
Agents as a team: lifecycle, merge queue, billing and a new shell227 return (
228 <DropdownMenu>
Workspace names and icons, and a component kit for every control229 {/* The name goes to the workspace; only the arrows switch it. The
230 name gets all the room there is, and the whole of it on hover. */}
A Vercel-like sidebar and a new mark231 <Link
232 to={current ? `/${current}` : "/workspaces/new"}
Workspace names and icons, and a component kit for every control233 title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}
234 className="flex h-8 min-w-0 grow items-center gap-1.5 rounded-md px-1 transition-colors hover:bg-raised"
A Vercel-like sidebar and a new mark235 >
Workspace names and icons, and a component kit for every control236 {workspace ? (
237 <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell238 ) : (
Workspace names and icons, and a component kit for every control239 <span className="size-5 shrink-0 rounded-md border border-dashed border-line-strong" />
Agents as a team: lifecycle, merge queue, billing and a new shell240 )}
Workspace names and icons, and a component kit for every control241 <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
A Vercel-like sidebar and a new mark242 </Link>
243 <DropdownMenuTrigger
244 aria-label="Switch workspace"
Workspace names and icons, and a component kit for every control245 className="flex h-8 w-5 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg"
A Vercel-like sidebar and a new mark246 >
247 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell248 </DropdownMenuTrigger>
Workspace names and icons, and a component kit for every control249 <DropdownMenuContent align="start" className="w-64">
Agents as a team: lifecycle, merge queue, billing and a new shell250 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
251 {(user.workspaces ?? []).map((membership) => (
252 <DropdownMenuItem asChild key={membership.slug}>
Workspace names and icons, and a component kit for every control253 <Link to={`/${membership.slug}`} title={displayName(membership)}>
254 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
255 <span className="flex min-w-0 grow flex-col leading-tight">
256 <span className="truncate">{displayName(membership)}</span>
257 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
258 </span>
259 <span className="shrink-0 text-[0.6875rem] text-faint capitalize">{membership.role}</span>
260 {membership.slug === current ? (
261 <Check className="shrink-0 text-accent" />
262 ) : (
263 <span className="size-4 shrink-0" aria-hidden="true" />
264 )}
Agents as a team: lifecycle, merge queue, billing and a new shell265 </Link>
266 </DropdownMenuItem>
267 ))}
268 <DropdownMenuSeparator />
269 <DropdownMenuItem asChild>
270 <Link to="/workspaces/new">
271 <Plus />
272 New workspace
273 </Link>
274 </DropdownMenuItem>
275 </DropdownMenuContent>
276 </DropdownMenu>
277 );
278}
279
Billing accounts, terms and enterprises; g1t is no longer free280/**
281 * This month's usage, and how close the workspace is to its usage limit,
282 * as Vercel shows a plan's usage.
283 */
A Vercel-like sidebar and a new mark284function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
Usage while free is shown at cost; agents get rustfmt and clippy285 if (shell.monthUsageMicros == null) return null;
286 const spent = shell.monthUsageMicros;
Billing accounts, terms and enterprises; g1t is no longer free287 const limit = shell.limit;
288 const ceiling = limit?.ceilingMicros ?? null;
289 const share = limit && ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
290 const tone = limit?.state === "stopped" ? "text-danger" : limit?.state === "warning" ? "text-warn" : "text-faint";
291 const bar = limit?.state === "stopped" ? "bg-danger" : limit?.state === "warning" ? "bg-warn" : "bg-accent";
A Vercel-like sidebar and a new mark292 return (
293 <Link
294 to={`/${slug}/-/usage`}
295 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
296 >
297 <span className="flex items-baseline justify-between text-xs">
298 <span className="font-medium text-fg">Usage</span>
299 <span className="text-faint">this month</span>
300 </span>
301 <span className="mt-2 flex items-baseline justify-between">
302 <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span>
Free while g1t is being built out; agents can check out their own forks303 {shell.free ? (
304 <span className="text-xs text-accent">Free for now</span>
Billing accounts, terms and enterprises; g1t is no longer free305 ) : limit?.comped ? (
306 <span className="text-xs text-accent">Comped</span>
Free while g1t is being built out; agents can check out their own forks307 ) : (
Billing accounts, terms and enterprises; g1t is no longer free308 ceiling != null && (
309 <span className={`text-xs ${tone}`}>
310 {limit?.state === "stopped" ? "Limit reached" : `$${(ceiling / MICROS_PER_DOLLAR).toFixed(2)} limit`}
Free while g1t is being built out; agents can check out their own forks311 </span>
312 )
A Vercel-like sidebar and a new mark313 )}
314 </span>
Billing accounts, terms and enterprises; g1t is no longer free315 {ceiling != null && !limit?.comped && (
316 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
317 <span
318 className={`block h-full rounded-full ${bar}`}
319 style={{ width: `${Math.max(share * 100, share > 0 ? 3 : 0)}%` }}
320 />
321 </span>
322 )}
A Vercel-like sidebar and a new mark323 </Link>
324 );
325}
326
327function AccountMenu({ user }: { user: User }) {
328 const submit = useSubmit();
329 return (
330 <DropdownMenu>
331 <DropdownMenuTrigger className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
Workspace names and icons, and a component kit for every control332 <Avatar name={user.username} image={user.avatar} size={22} />
A Vercel-like sidebar and a new mark333 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
334 <Ellipsis size={15} className="shrink-0 text-faint" />
335 </DropdownMenuTrigger>
336 <DropdownMenuContent align="start" side="top" className="w-56">
337 <DropdownMenuLabel>
338 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
339 </DropdownMenuLabel>
340 <DropdownMenuSeparator />
341 <DropdownMenuItem asChild>
342 <Link to="/settings">
343 <Settings />
344 Your settings
345 </Link>
346 </DropdownMenuItem>
347 <DropdownMenuItem asChild>
348 <a href="https://docs.g1t.sh/">
349 <BookOpen />
350 Documentation
351 </a>
352 </DropdownMenuItem>
353 <DropdownMenuSeparator />
354 {/* Submitted from here: the menu closes on select, and a button
355 that has left the page cannot submit a form. */}
356 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
357 <LogOut />
358 Sign out
359 </DropdownMenuItem>
360 </DropdownMenuContent>
361 </DropdownMenu>
362 );
363}
364
A catalogue of model providers, and settings that feel like settings365/** A workspace's settings pages, which the sidebar slides over to. */
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs366const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
A catalogue of model providers, and settings that feel like settings367
Polish: phones, copy boxes, the plan page, the landing page, a real glide368/**
Sidebar: no settings flash on the way into a repository, and a better slide369 * The sidebar's menus are two layers, the way a phone pushes a screen: the
370 * one arriving slides in from the right over the full width, while the one
371 * leaving drifts a quarter of the way left and fades, both on one long
Sidebar: the panels really slide372 * ease-out, the fade quicker than the move so the two never blur together.
373 * Going back reverses it.
Polish: phones, copy boxes, the plan page, the landing page, a real glide374 */
Sidebar: no settings flash on the way into a repository, and a better slide375const LAYER =
Sidebar: the panels really slide376 "absolute inset-0 [transition:translate_380ms_cubic-bezier(0.32,0.72,0,1),opacity_220ms_ease-out] will-change-[translate,opacity] motion-reduce:transition-none";
Sidebar: no settings flash on the way into a repository, and a better slide377/** One menu, filling its layer. */
378const PANEL = "h-full overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings379
380/** A workspace's settings, as the sidebar shows them in place of everything else. */
381function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
382 return (
383 <nav
384 aria-label="Workspace settings"
385 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide386 className={PANEL}
A catalogue of model providers, and settings that feel like settings387 >
388 <Link
389 to={`/${slug}`}
390 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
391 >
392 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
393 <span className="truncate">{slug}</span>
394 </Link>
395 <SidebarGroup title="Settings">
396 {owner && (
397 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
398 General
399 </SidebarLink>
400 )}
401 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
402 Members
403 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments404 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
405 Access tokens
406 </SidebarLink>
A catalogue of model providers, and settings that feel like settings407 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
Secrets and variables: one list, rows per environment, for workflows and deployments408 Billing and plans
A catalogue of model providers, and settings that feel like settings409 </SidebarLink>
410 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
411 Integrations
Webhooks: every event, to your own addresses, signed and retried412 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs413 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
414 Secrets and variables
415 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments416 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
417 Webhooks
A catalogue of model providers, and settings that feel like settings418 </SidebarLink>
419 </SidebarGroup>
420 </nav>
421 );
422}
423
Sidebar: no settings flash on the way into a repository, and a better slide424/**
425 * What the repository menu needs. Known from the address before the page's
426 * data arrives, so the menu is right from the first frame; the counts fill
427 * in once it does.
428 */
429type MenuRepo = {
430 namespace: string;
431 name: string;
432 member: boolean;
433 issues?: number;
434 pulls?: number;
435};
436
437function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
438 return (
439 a != null &&
440 b != null &&
441 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
442 a.name.toLowerCase() === b.name.toLowerCase()
443 );
444}
A repository has its own sidebar, as settings do445
446/**
Projects: what a workspace builds and runs, first on every page447 * A project's own menu, which the sidebar slides to while you are in it,
448 * as it does for settings: everything about the project, running and its
449 * code, and nothing else, with the way back to everything.
A repository has its own sidebar, as settings do450 */
Sidebar: no settings flash on the way into a repository, and a better slide451function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
A repository has its own sidebar, as settings do452 const base = `/${repo.namespace}/${repo.name}`;
453 return (
454 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
455 <Link
456 to="/"
457 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
458 >
459 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
460 Mission control
461 </Link>
A flat project menu with Code first, views as tabs, and Stripe for every owner462 <NavLink
A repository has its own sidebar, as settings do463 to={base}
A flat project menu with Code first, views as tabs, and Stripe for every owner464 end
465 title="Overview"
466 className={({ isActive }) =>
467 `mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
468 }
A repository has its own sidebar, as settings do469 >
470 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
Projects: what a workspace builds and runs, first on every page471 {isPrivate ? <Lock size={13} /> : <Box size={13} />}
A repository has its own sidebar, as settings do472 </span>
473 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
474 <span className="text-faint">{repo.namespace}/</span>
475 <span className="font-semibold text-fg">{repo.name}</span>
476 </span>
A flat project menu with Code first, views as tabs, and Stripe for every owner477 </NavLink>
478 {/* A project's pages, flat and in the order people use them. A page
479 with more than one view shows them as tabs across its top. */}
Projects: what a workspace builds and runs, first on every page480 <div className="mt-3 space-y-px">
A flat project menu with Code first, views as tabs, and Stripe for every owner481 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
482 Code
483 </SidebarLink>
484 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
485 Issues
486 </SidebarLink>
487 <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
488 Pull requests
489 </SidebarLink>
Agents and memory, checks and conflicts, profiles, slug renames, custom domains490 <SidebarLink to={`${base}/agents`} also={[`${base}/sessions`, `${base}/memory`, ...soonPaths(base, "Agents")]} icon={<Bot size={15} />}>
A flat project menu with Code first, views as tabs, and Stripe for every owner491 Agents
Agents and memory, checks and conflicts, profiles, slug renames, custom domains492 </SidebarLink>
A flat project menu with Code first, views as tabs, and Stripe for every owner493 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
494 Workflows
Projects: what a workspace builds and runs, first on every page495 </SidebarLink>
A flat project menu with Code first, views as tabs, and Stripe for every owner496 {repo.member ? (
497 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
498 Deployments
499 </SidebarLink>
500 ) : null}
Observability gets its own page; tabs never overflow501 <SidebarSoonLink to={`${base}/soon/logs`} also={soonPaths(base, "Observability")} icon={<Activity size={15} />} about="Logs, errors, uptime and analytics of the project's deployed apps.">
502 Observability
503 </SidebarSoonLink>
A flat project menu with Code first, views as tabs, and Stripe for every owner504 <SidebarSoonLink to={`${base}/soon/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />} about="Findings, secret scanning, dependency updates and code scanning, each fixed by an agent.">
505 Security
506 </SidebarSoonLink>
507 <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do.">
508 Insights
509 </SidebarSoonLink>
Projects: what a workspace builds and runs, first on every page510 {repo.member && (
A flat project menu with Code first, views as tabs, and Stripe for every owner511 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
512 Settings
513 </SidebarLink>
Projects: what a workspace builds and runs, first on every page514 )}
515 </div>
A repository has its own sidebar, as settings do516 </nav>
517 );
518}
519
Account settings in the same shape as a workspace's520/** Your own settings, as the sidebar shows them on the settings page. */
521function AccountSettingsMenu({ open }: { open: boolean }) {
522 const { hash } = useLocation();
523 const item = (id: string, icon: ReactNode, label: string) => (
524 <Link
525 to={`/settings#${id}`}
526 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
527 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
528 }`}
529 >
530 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
531 {label}
532 </Link>
533 );
534 return (
535 <nav
536 aria-label="Your settings"
537 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide538 className={PANEL}
Account settings in the same shape as a workspace's539 >
540 <Link
541 to="/"
542 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
543 >
544 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
545 Mission control
546 </Link>
547 <SidebarGroup title="Account">
Secrets and variables: one list, rows per environment, for workflows and deployments548 {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")}
549 {item("tokens", <KeyRound size={15} />, "Access tokens")}
Account settings in the same shape as a workspace's550 {item("applications", <Plug size={15} />, "Connected applications")}
551 </SidebarGroup>
552 </nav>
553 );
554}
555
A Vercel-like sidebar and a new mark556function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell557 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings558 const { pathname } = useLocation();
559 const going = useNavigation().location?.pathname;
560 // On a workspace's settings page, or on the way to one, its settings take
561 // the sidebar over.
562 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's563 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do564 const active = shell.repo;
565 // In a repository, or on the way into one, its own menu takes the sidebar.
566 const target = going ?? pathname;
567 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains568 const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
A repository has its own sidebar, as settings do569 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
570 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide571 // The repository the menu is for: the one loaded if it is the one being
572 // gone to, else what the address says, at once.
573 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
574 const menuRepo: MenuRepo | null = targetRepo
575 ? sameRepo(active, targetRepo)
576 ? active
577 : {
578 ...targetRepo,
579 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
580 }
581 : null;
A repository has its own sidebar, as settings do582 // What sits on the far side of the track. Kept while sliding back, so it
583 // does not vanish on the way out.
584 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide585 if (inAccount) side.current = "account";
586 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do587 else if (inRepo) side.current = "repo";
588 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide589 const shown = useRef<MenuRepo | null>(menuRepo);
590 if (menuRepo) shown.current = menuRepo;
591 // Moving between two of the far-side menus (settings to a repository,
592 // one repository to another) crossfades in place instead of sliding.
593 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
594 const lastAway = useRef(away);
595 const lastKey = useRef(detailKey);
596 const swapped = lastAway.current && away && lastKey.current !== detailKey;
597 useEffect(() => {
598 lastAway.current = away;
599 lastKey.current = detailKey;
600 });
Agents as a team: lifecycle, merge queue, billing and a new shell601 // The repository being looked at is listed even when it is someone else's.
602 const listed =
603 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
604 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
605 : shell.repos;
606 return (
607 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark608 {/* The same height and rule as the top bar, so the two read as one line. */}
Workspace names and icons, and a component kit for every control609 <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
610 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
Two limits, real invoices, trust that grows by itself, sales signals611 <Mark className="size-6" />
Agents as a team: lifecycle, merge queue, billing and a new shell612 </Link>
A Vercel-like sidebar and a new mark613 <span className="shrink-0 text-line-strong" aria-hidden="true">
614 /
615 </span>
616 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell617 </div>
A Vercel-like sidebar and a new mark618 <div className="px-2 pt-3">
619 <button
620 type="button"
621 onClick={onFind}
Two limits, real invoices, trust that grows by itself, sales signals622 className="flex h-9 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong"
A Vercel-like sidebar and a new mark623 >
624 <Search size={14} />
625 <span className="grow text-left">Find…</span>
626 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
627 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell628 </div>
Sidebar: no settings flash on the way into a repository, and a better slide629 <div className="relative min-h-0 grow overflow-hidden">
630 <div className={`${LAYER} ${away ? "pointer-events-none -translate-x-1/4 opacity-0" : "translate-x-0 opacity-100"}`}>
Polish: phones, copy boxes, the plan page, the landing page, a real glide631 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell632 <div className="mt-3 space-y-px">
633 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
634 Mission control
635 </SidebarLink>
636 <SidebarLink to="/explore" icon={<Compass size={15} />}>
637 Explore
638 </SidebarLink>
639 </div>
640
641 <SidebarGroup
Projects: what a workspace builds and runs, first on every page642 title="Projects"
Agents as a team: lifecycle, merge queue, billing and a new shell643 action={
644 ws && (
645 <Link
646 to={`/new?workspace=${ws.slug}`}
Projects: what a workspace builds and runs, first on every page647 aria-label="New project"
Agents as a team: lifecycle, merge queue, billing and a new shell648 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
649 >
650 <Plus size={13} />
651 </Link>
652 )
653 }
654 >
655 {listed.length === 0 && (
656 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
657 )}
658 {listed.map((repo) => {
659 const base = `/${repo.namespace}/${repo.name}`;
660 return (
661 <div key={base}>
662 <SidebarLink
663 to={base}
Projects: what a workspace builds and runs, first on every page664 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
Agents as a team: lifecycle, merge queue, billing and a new shell665 >
Projects: what a workspace builds and runs, first on every page666 <span className="text-[0.8125rem]">
Agents as a team: lifecycle, merge queue, billing and a new shell667 {repo.namespace !== ws?.slug && (
Projects: what a workspace builds and runs, first on every page668 <span className="font-mono text-faint">{repo.namespace}/</span>
Agents as a team: lifecycle, merge queue, billing and a new shell669 )}
Projects: what a workspace builds and runs, first on every page670 {"title" in repo && repo.title ? repo.title : repo.name}
Agents as a team: lifecycle, merge queue, billing and a new shell671 </span>
672 </SidebarLink>
673 </div>
674 );
675 })}
676 </SidebarGroup>
A flat project menu with Code first, views as tabs, and Stripe for every owner677 {ws && (
678 <SidebarGroup title="Across projects">
Agents and memory, checks and conflicts, profiles, slug renames, custom domains679 <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}>
680 Agent fleet
681 </SidebarLink>
682 <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />}>
683 Memory
684 </SidebarLink>
A flat project menu with Code first, views as tabs, and Stripe for every owner685 {roadmapIn("Workspace").map((item) => (
686 <SidebarSoonLink
687 key={item.key}
688 to={`/${ws.slug}/-/soon/${item.key}`}
689 icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />}
690 about={item.summary}
691 >
692 {item.title === "Board" ? "Boards" : item.title}
693 </SidebarSoonLink>
694 ))}
695 </SidebarGroup>
696 )}
Agents as a team: lifecycle, merge queue, billing and a new shell697 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide698 </div>
Sidebar: no settings flash on the way into a repository, and a better slide699 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
700 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
701 {side.current === "repo" && shown.current ? (
702 <RepoMenu
703 repo={shown.current}
704 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
705 open={inRepo}
706 />
707 ) : side.current === "account" || !ws ? (
708 <AccountSettingsMenu open={inAccount} />
709 ) : (
710 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
711 )}
712 </div>
713 </div>
A catalogue of model providers, and settings that feel like settings714 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell715
The project menu opens by section, and the workspace has its own panel716 {/* The workspace's own things sit at the bottom, by its usage and the
717 account: a panel of their own, ruled off from whatever menu is above,
718 however long it grows. */}
719 <div className="shrink-0 space-y-2 border-t border-line bg-surface/50 p-2 pt-3">
Workspace things at the bottom of the sidebar, and on mission control720 {ws && (
721 <div className="space-y-px">
The project menu opens by section, and the workspace has its own panel722 <p className="flex items-center gap-2 px-2 pb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-faint">
723 <span>Workspace</span>
724 <span className="min-w-0 truncate font-mono normal-case tracking-normal text-muted">{ws.slug}</span>
725 </p>
Workspace things at the bottom of the sidebar, and on mission control726 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
727 Usage
728 </SidebarLink>
729 {/* Everything else about the workspace lives in its settings, and only there. */}
730 <SidebarLink
731 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
732 icon={<Settings size={15} />}
733 >
734 Settings
735 </SidebarLink>
736 </div>
737 )}
A Vercel-like sidebar and a new mark738 {ws && <UsageCard slug={ws.slug} shell={shell} />}
739 <AccountMenu user={user} />
740 </div>
741 </div>
742 );
743}
744
745/** Words for the sections a path can end in. */
746const SECTIONS: Record<string, string> = {
747 issues: "Issues",
748 pulls: "Pull requests",
749 queue: "Merge queue",
750 commits: "Commits",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales751 plans: "Outcomes",
752 actions: "Workflows",
753 soon: "Soon",
Deployments: a preview for every pull request, production on g1t.page754 deployments: "Deployments",
Projects: what a workspace builds and runs, first on every page755 repository: "Repository",
Project dependencies: addresses, preview stacks, Affects, and agents who know756 dependencies: "Dependencies",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales757 code: "Files",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs758 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark759 settings: "Settings",
A catalogue of model providers, and settings that feel like settings760 people: "Members",
A Vercel-like sidebar and a new mark761 tokens: "Access tokens",
762 usage: "Usage",
Secrets and variables: one list, rows per environment, for workflows and deployments763 billing: "Billing and plans",
A catalogue of model providers, and settings that feel like settings764 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried765 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark766 tree: "Code",
767 blob: "Code",
768};
769
770/** Where the page is, as a trail of links: workspace / repository / section. */
771function Breadcrumbs({ pathname }: { pathname: string }) {
772 const parts = pathname.split("/").filter(Boolean);
773 const reserved = ["settings", "explore", "new", "search", "workspaces"];
774 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
775 if (reserved.includes(parts[0]!)) {
776 const words: Record<string, string> = {
Account settings in the same shape as a workspace's777 settings: "Account",
A Vercel-like sidebar and a new mark778 explore: "Explore",
Projects live: fixes from walking them end to end779 new: "New project",
A Vercel-like sidebar and a new mark780 search: "Search",
781 workspaces: "New workspace",
782 };
783 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
784 }
785 const [owner, second, third, fourth] = parts;
786 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
787 if (second === "-") {
A catalogue of model providers, and settings that feel like settings788 const page = `/${owner}/-/${third}`;
789 if (third && SETTINGS_PAGE.test(page)) {
790 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
791 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
792 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark793 } else if (second) {
794 const repo = `/${owner}/${second}`;
795 trail.push({ label: second, to: repo, mono: true });
796 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
797 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
798 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
Secrets and variables: one list, rows per environment, for workflows and deployments799 else if (third && SECTIONS[third]) {
800 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
801 // A settings page names which one: Settings / Secrets and variables.
802 if (third === "settings" && fourth && SECTIONS[fourth]) {
803 trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
804 }
805 }
A Vercel-like sidebar and a new mark806 }
807 return (
808 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
809 {trail.map((crumb, index) => (
810 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
811 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell812 <Link
A Vercel-like sidebar and a new mark813 to={crumb.to}
814 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
815 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
816 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell817 >
A Vercel-like sidebar and a new mark818 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell819 </Link>
A Vercel-like sidebar and a new mark820 </span>
821 ))}
822 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell823 );
824}
825
826type Command = { label: string; hint?: string; to: string; icon: ReactNode };
827
828/** Everything the palette can jump to, from what the sidebar already knows. */
829function commandsFor(user: User, shell: ShellData): Command[] {
830 const commands: Command[] = [
831 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
832 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
Projects: what a workspace builds and runs, first on every page833 { label: "New project", to: "/new", icon: <Plus size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell834 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
835 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
836 ];
837 const repo = shell.repo;
838 if (repo) {
839 const base = `/${repo.namespace}/${repo.name}`;
840 const name = `${repo.namespace}/${repo.name}`;
841 commands.unshift(
Projects: what a workspace builds and runs, first on every page842 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
843 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell844 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
845 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
846 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
847 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
848 ...(repo.member
849 ? [
850 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
Projects: what a workspace builds and runs, first on every page851 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
852 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
853 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
854 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell855 ]
856 : []),
857 );
858 }
859 for (const membership of user.workspaces ?? []) {
860 commands.push(
Workspace names and icons, and a component kit for every control861 { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request862 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
Projects: what a workspace builds and runs, first on every page863 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
A catalogue of model providers, and settings that feel like settings864 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
865 { label: "Integrations", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell866 );
867 }
868 for (const listed of shell.repos) {
869 commands.push({
Projects: what a workspace builds and runs, first on every page870 label: listed.title ?? listed.name,
871 hint: `${listed.namespace} · Project`,
Agents as a team: lifecycle, merge queue, billing and a new shell872 to: `/${listed.namespace}/${listed.name}`,
Projects: what a workspace builds and runs, first on every page873 icon: <Box size={15} />,
Agents as a team: lifecycle, merge queue, billing and a new shell874 });
875 }
876 return commands;
877}
878
879function CommandPalette({
880 open,
881 onClose,
882 commands,
883}: {
884 open: boolean;
885 onClose: () => void;
886 commands: Command[];
887}) {
888 const navigate = useNavigate();
889 const [query, setQuery] = useState("");
890 const [selected, setSelected] = useState(0);
891 const input = useRef<HTMLInputElement>(null);
892 const matches = useMemo(() => {
893 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
894 return commands
895 .filter((command) => {
896 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
897 return words.every((word) => text.includes(word));
898 })
899 .slice(0, 12);
900 }, [commands, query]);
901
902 useEffect(() => {
903 if (!open) return;
904 setQuery("");
905 setSelected(0);
906 input.current?.focus();
907 }, [open]);
908 useEffect(() => setSelected(0), [query]);
909
910 if (!open) return null;
911 const go = (command: Command | undefined) => {
912 if (!command) return;
913 onClose();
914 navigate(command.to);
915 };
916 return (
917 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
918 <div
919 role="dialog"
920 aria-label="Go to"
921 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
922 onClick={(event) => event.stopPropagation()}
923 >
924 <div className="flex items-center gap-3 border-b border-line px-4">
925 <Search size={16} className="text-faint" />
926 <input
927 ref={input}
928 value={query}
929 onChange={(event) => setQuery(event.target.value)}
930 onKeyDown={(event) => {
931 if (event.key === "ArrowDown") {
932 event.preventDefault();
933 setSelected((index) => Math.min(index + 1, matches.length - 1));
934 } else if (event.key === "ArrowUp") {
935 event.preventDefault();
936 setSelected((index) => Math.max(index - 1, 0));
937 } else if (event.key === "Enter") {
938 event.preventDefault();
939 go(matches[selected]);
940 } else if (event.key === "Escape") {
941 onClose();
942 }
943 }}
944 placeholder="Go to a repository, a page, or an action…"
945 autoComplete="off"
946 data-1p-ignore
947 className="h-12 grow border-0 bg-transparent text-sm shadow-none outline-none ring-0 placeholder:text-faint focus:outline-none focus-visible:outline-none"
948 />
949 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
950 </div>
951 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
952 {matches.length === 0 && (
953 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
954 )}
955 {matches.map((command, index) => (
956 <li key={`${command.to}-${command.label}`}>
957 <button
958 type="button"
959 onMouseEnter={() => setSelected(index)}
960 onClick={() => go(command)}
961 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
962 index === selected ? "bg-line text-fg" : "text-muted"
963 }`}
964 >
965 <span className="shrink-0 text-faint">{command.icon}</span>
966 <span className="min-w-0 grow truncate">{command.label}</span>
967 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
968 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
969 </button>
970 </li>
971 ))}
972 </ul>
973 </div>
974 </div>
975 );
976}
977
978/**
979 * A bar across the top of the page while the next one loads, as GitHub
980 * has: it appears at once, creeps towards the end while waiting, then fills
981 * and fades when the page arrives.
982 */
983export function Progress() {
984 const navigation = useNavigation();
985 const busy = navigation.state !== "idle";
986 const [width, setWidth] = useState(0);
987 const [visible, setVisible] = useState(false);
988 useEffect(() => {
989 if (busy) {
990 setVisible(true);
991 setWidth(12);
992 // Slows as it goes, never quite arriving until the page does.
993 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
994 return () => clearInterval(timer);
995 }
996 setWidth((now) => (now > 0 ? 100 : 0));
997 const fade = setTimeout(() => setVisible(false), 250);
998 const reset = setTimeout(() => setWidth(0), 550);
999 return () => {
1000 clearTimeout(fade);
1001 clearTimeout(reset);
1002 };
1003 }, [busy]);
1004 return (
1005 <div
1006 aria-hidden="true"
1007 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
1008 visible ? "opacity-100" : "opacity-0"
1009 }`}
1010 >
1011 <div
1012 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
1013 style={{ width: `${width}%` }}
1014 />
1015 </div>
1016 );
1017}
1018
1019/**
1020 * The signed-in app: a sidebar with the workspace, its repositories and the
1021 * sections of the one being looked at; a slim bar with search and the
1022 * account; and the page.
1023 */
1024export function AppShell({
1025 user,
1026 shell,
1027 banner,
1028 children,
1029}: {
1030 user: User;
1031 shell: ShellData;
1032 banner?: ReactNode;
1033 children: ReactNode;
1034}) {
1035 const submit = useSubmit();
1036 const { pathname } = useLocation();
1037 const [drawer, setDrawer] = useState(false);
1038 const [palette, setPalette] = useState(false);
1039 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
1040
1041 // A new page closes the drawer on small screens.
1042 useEffect(() => setDrawer(false), [pathname]);
1043 useEffect(() => {
1044 const onKey = (event: KeyboardEvent) => {
1045 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
1046 event.preventDefault();
1047 setPalette((open) => !open);
1048 }
1049 };
1050 window.addEventListener("keydown", onKey);
1051 return () => window.removeEventListener("keydown", onKey);
1052 }, []);
1053
1054 return (
1055 <div className="min-h-screen">
1056 <Progress />
1057 <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block">
A Vercel-like sidebar and a new mark1058 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1059 </aside>
1060 {drawer && (
1061 <div className="fixed inset-0 z-50 lg:hidden">
1062 <button
1063 type="button"
1064 aria-label="Close menu"
1065 className="absolute inset-0 bg-black/60"
1066 onClick={() => setDrawer(false)}
1067 />
1068 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
1069 <button
1070 type="button"
1071 aria-label="Close menu"
1072 onClick={() => setDrawer(false)}
1073 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
1074 >
1075 <X size={16} />
1076 </button>
A Vercel-like sidebar and a new mark1077 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1078 </aside>
1079 </div>
1080 )}
1081
Polish: phones, copy boxes, the plan page, the landing page, a real glide1082 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
Two limits, real invoices, trust that grows by itself, sales signals1083 <header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6">
Agents as a team: lifecycle, merge queue, billing and a new shell1084 <button
1085 type="button"
1086 aria-label="Open menu"
1087 onClick={() => setDrawer(true)}
1088 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
1089 >
1090 <Menu size={18} />
1091 </button>
A Vercel-like sidebar and a new mark1092 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell1093 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark1094 <a
1095 href="https://docs.g1t.sh/"
Two limits, real invoices, trust that grows by itself, sales signals1096 className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
A Vercel-like sidebar and a new mark1097 >
1098 Docs
1099 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell1100 <DropdownMenu>
1101 <DropdownMenuTrigger
1102 aria-label="Create"
Two limits, real invoices, trust that grows by itself, sales signals1103 className="flex h-9 items-center gap-1.5 rounded-md bg-fg px-3 text-sm font-medium text-bg outline-none transition-colors hover:bg-white"
Agents as a team: lifecycle, merge queue, billing and a new shell1104 >
A Vercel-like sidebar and a new mark1105 <Plus size={14} />
1106 New
Agents as a team: lifecycle, merge queue, billing and a new shell1107 </DropdownMenuTrigger>
1108 <DropdownMenuContent align="end">
1109 {shell.repo && (
1110 <DropdownMenuItem asChild>
1111 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
1112 <CircleDot />
1113 New issue
1114 </Link>
1115 </DropdownMenuItem>
1116 )}
1117 {shell.repo?.member && (
1118 <DropdownMenuItem asChild>
1119 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
1120 <ListTree />
1121 Plan work
1122 </Link>
1123 </DropdownMenuItem>
1124 )}
1125 <DropdownMenuItem asChild>
1126 <Link to="/new">
Projects: what a workspace builds and runs, first on every page1127 <Box />
1128 New project
Agents as a team: lifecycle, merge queue, billing and a new shell1129 </Link>
1130 </DropdownMenuItem>
1131 <DropdownMenuItem asChild>
1132 <Link to="/workspaces/new">
1133 <Users />
1134 New workspace
1135 </Link>
1136 </DropdownMenuItem>
1137 </DropdownMenuContent>
1138 </DropdownMenu>
1139 </div>
1140 </header>
1141 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide1142 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell1143 </div>
1144 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
1145 </div>
1146 );
1147}