flagon-io/g1t

public

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

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

1,255 lines48,597 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 {
A catalogue of model providers, and settings that feel like settings2 ArrowLeft,
The project menu opens by section, and the workspace has its own panel3 ChevronRight,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request4 BarChart3,
Agents as a team: lifecycle, merge queue, billing and a new shell5 BookMarked,
6 BookOpen,
7 Check,
8 ChevronsUpDown,
A Vercel-like sidebar and a new mark9 Ellipsis,
Agents as a team: lifecycle, merge queue, billing and a new shell10 CircleDot,
11 Code2,
12 Compass,
13 CornerDownLeft,
14 CreditCard,
15 GitPullRequest,
16 History,
Secrets and variables: one list, rows per environment, for workflows and deployments17 Fingerprint,
Agents as a team: lifecycle, merge queue, billing and a new shell18 KeyRound,
Projects: what a workspace builds and runs, first on every page19 Box,
Agents as a team: lifecycle, merge queue, billing and a new shell20 LayoutDashboard,
Projects: what a workspace builds and runs, first on every page21 LayoutGrid,
Agents as a team: lifecycle, merge queue, billing and a new shell22 ListTree,
23 Lock,
24 LogOut,
25 Menu,
26 Plus,
27 Search,
Integrations: your own model provider, alerts that open issues, tickets agents read28 Plug,
Agents as a team: lifecycle, merge queue, billing and a new shell29 Settings,
30 Users,
Webhooks: every event, to your own addresses, signed and retried31 Webhook,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs32 PlayCircle,
Pull requests say where they are live; the project menu shows what is coming33 Activity,
34 Bot,
35 Kanban,
36 ShieldCheck,
Deployments: a preview for every pull request, production on g1t.page37 Rocket,
Agents as a team: lifecycle, merge queue, billing and a new shell38 X,
39} from "lucide-react";
40import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
41import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
42
43import type { User } from "@g1t/contracts";
44import { MICROS_PER_DOLLAR } from "@g1t/contracts";
45
A Vercel-like sidebar and a new mark46import { Mark } from "./logo";
Agents as a team: lifecycle, merge queue, billing and a new shell47import { Avatar } from "./ui";
48import {
49 DropdownMenu,
50 DropdownMenuContent,
51 DropdownMenuItem,
52 DropdownMenuLabel,
53 DropdownMenuSeparator,
54 DropdownMenuTrigger,
55} from "./ui/dropdown-menu";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales56import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
Agents as a team: lifecycle, merge queue, billing and a new shell57
58/** What the sidebar needs, worked out by the root loader for a signed-in person. */
59export type ShellData = {
60 /** The workspace the sidebar is about: the one being looked at, or their first. */
61 workspace: { slug: string; role: "owner" | "member" } | null;
Projects: what a workspace builds and runs, first on every page62 /** Its projects, by name: `name` is the slug in their address. */
63 repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
64 /** The project being looked at, if any, whoever owns it. */
Agents as a team: lifecycle, merge queue, billing and a new shell65 repo: {
66 namespace: string;
67 name: string;
68 member: boolean;
69 issues: number;
70 pulls: number;
71 } | null;
Billing accounts, terms and enterprises; g1t is no longer free72 /** Where the workspace stands against its usage limit, if billing is on. */
73 limit: {
74 exposureMicros: number;
75 ceilingMicros: number | null;
76 state: "ok" | "warning" | "stopped";
77 comped: boolean;
78 } | null;
Free while g1t is being built out; agents can check out their own forks79 /** Whether g1t charges nothing for now, while it is being built out. */
80 free?: boolean;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request81 /** What its agents have cost since the start of the month. */
Usage while free is shown at cost; agents get rustfmt and clippy82 /** This month's usage: charged, or at cost while g1t is free. */
83 monthUsageMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell84};
85
86function SidebarLink({
87 to,
88 icon,
89 end,
90 count,
91 also,
92 children,
93}: {
94 to: string;
95 icon: ReactNode;
96 end?: boolean;
97 count?: number;
Projects: what a workspace builds and runs, first on every page98 /** Other path prefixes under which this link is the current one. */
99 also?: string | string[];
Agents as a team: lifecycle, merge queue, billing and a new shell100 children: ReactNode;
101}) {
102 const { pathname } = useLocation();
103 return (
104 <NavLink
105 to={to}
106 end={end}
107 prefetch="intent"
108 className={({ isActive, isPending }) => {
Projects: what a workspace builds and runs, first on every page109 const current =
110 isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
A Vercel-like sidebar and a new mark111 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 shell112 current
113 ? "bg-raised font-medium text-fg"
114 : isPending
115 ? "bg-raised/60 text-fg"
116 : "text-muted hover:bg-raised/60 hover:text-fg"
117 }`;
118 }}
119 >
120 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
121 <span className="min-w-0 grow truncate">{children}</span>
122 {count != null && count > 0 && (
123 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
124 {count}
125 </span>
126 )}
127 </NavLink>
128 );
129}
130
Pull requests say where they are live; the project menu shows what is coming131/**
132 * Something a project will have, shown where it will live so people can
133 * see where g1t is going. Not a link: nothing is behind it yet.
134 */
135function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
136 return (
137 <div
138 title={about}
139 aria-disabled="true"
140 className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint"
141 >
142 <span className="shrink-0 opacity-70">{icon}</span>
143 <span className="grow truncate">{children}</span>
144 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
145 Soon
146 </span>
147 </div>
148 );
149}
150
The project menu opens by section, and the workspace has its own panel151/**
152 * A section of the project menu that opens to show its pages, as GitLab's
153 * does: one row with an icon, and its pages indented under it. It opens
154 * by itself when one of its pages is the current one, and otherwise
155 * remembers whether it was left open.
156 */
157function SidebarSection({
158 title,
159 icon,
160 paths = [],
161 soon = false,
162 children,
163}: {
164 title: string;
165 icon: ReactNode;
166 /** Path prefixes of its pages: one being current opens it. */
167 paths?: string[];
168 /** Nothing in it is built yet. */
169 soon?: boolean;
170 children: ReactNode;
171}) {
172 const { pathname } = useLocation();
173 const current = paths.some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
174 const key = `g1t.sidebar.${title}`;
175 const [open, setOpen] = useState<boolean>(current);
176 // What the person left it as, once the page is in the browser.
177 useEffect(() => {
178 if (current) {
179 setOpen(true);
180 return;
181 }
182 try {
183 const saved = localStorage.getItem(key);
184 if (saved != null) setOpen(saved === "1");
185 } catch {
186 // No storage: it stays as it is.
187 }
188 }, [current, key]);
189 const toggle = () => {
190 const next = !open;
191 setOpen(next);
192 try {
193 localStorage.setItem(key, next ? "1" : "0");
194 } catch {
195 // Remembered for this page only.
196 }
197 };
198 return (
199 <section>
200 <button
201 type="button"
202 onClick={toggle}
203 aria-expanded={open}
204 className={`group flex h-8 w-full items-center gap-2.5 rounded-md px-2 text-left text-[0.8125rem] transition-colors hover:bg-raised/60 ${
205 current ? "text-fg" : soon ? "text-faint hover:text-muted" : "text-muted hover:text-fg"
206 }`}
207 >
208 <span className={`shrink-0 ${current ? "text-muted" : "text-faint group-hover:text-muted"}`}>{icon}</span>
209 <span className={`min-w-0 grow truncate ${current ? "font-medium" : ""}`}>{title}</span>
210 {soon && !open && (
211 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
212 Soon
213 </span>
214 )}
215 <ChevronRight
216 size={14}
217 className={`shrink-0 text-faint transition-transform duration-150 ${open ? "rotate-90" : ""}`}
218 aria-hidden="true"
219 />
220 </button>
221 {open && (
222 <div className="relative mt-px mb-1 space-y-px before:absolute before:top-1 before:bottom-1 before:left-[1.1875rem] before:w-px before:bg-line">
223 {children}
224 </div>
225 )}
226 </section>
227 );
228}
229
230/** A page inside a section: indented, without an icon of its own. */
231function SidebarSubLink({
232 to,
233 end,
234 count,
235 also,
236 children,
237}: {
238 to: string;
239 end?: boolean;
240 count?: number;
241 also?: string | string[];
242 children: ReactNode;
243}) {
244 const { pathname } = useLocation();
245 return (
246 <NavLink
247 to={to}
248 end={end}
249 prefetch="intent"
250 className={({ isActive, isPending }) => {
251 const current =
252 isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
253 return `relative flex h-8 items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] transition-colors ${
254 current
255 ? "bg-raised font-medium text-fg before:absolute before:top-1.5 before:bottom-1.5 before:left-[1.1875rem] before:z-10 before:w-px before:bg-accent"
256 : isPending
257 ? "bg-raised/60 text-fg"
258 : "text-muted hover:bg-raised/60 hover:text-fg"
259 }`;
260 }}
261 >
262 <span className="min-w-0 grow truncate">{children}</span>
263 {count != null && count > 0 && (
264 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">{count}</span>
265 )}
266 </NavLink>
267 );
268}
269
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales270/**
271 * A page a section will have: a link to a page saying what it will be,
272 * so people can see where g1t is going, and read about it.
273 */
274function SidebarSubSoon({ to, about, children }: { to: string; about: string; children: ReactNode }) {
The project menu opens by section, and the workspace has its own panel275 return (
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales276 <NavLink
277 to={to}
The project menu opens by section, and the workspace has its own panel278 title={about}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales279 prefetch="intent"
280 className={({ isActive }) =>
281 `relative flex h-8 items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] transition-colors ${
282 isActive
283 ? "bg-raised text-fg before:absolute before:top-1.5 before:bottom-1.5 before:left-[1.1875rem] before:z-10 before:w-px before:bg-accent"
284 : "text-faint hover:bg-raised/60 hover:text-muted"
285 }`
286 }
The project menu opens by section, and the workspace has its own panel287 >
288 <span className="grow truncate">{children}</span>
289 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
290 Soon
291 </span>
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales292 </NavLink>
The project menu opens by section, and the workspace has its own panel293 );
294}
295
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales296/** A section's Soon pages, from the roadmap. */
297function SoonItems({ base, section }: { base: string; section: RoadmapItem["section"] }) {
298 return (
299 <>
300 {roadmapIn(section).map((item) => (
301 <SidebarSubSoon key={item.key} to={`${base}/soon/${item.key}`} about={item.summary}>
302 {item.title}
303 </SidebarSubSoon>
304 ))}
305 </>
306 );
307}
308
309/** A section's own pages and its Soon pages, for opening it when current. */
310function sectionPaths(base: string, section: RoadmapItem["section"], own: string[]): string[] {
311 return [...own, ...roadmapIn(section).map((item) => `${base}/soon/${item.key}`)];
312}
313
Agents as a team: lifecycle, merge queue, billing and a new shell314function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
315 return (
A Vercel-like sidebar and a new mark316 <section className="mt-6">
317 <div className="mb-1 flex h-6 items-center justify-between px-2">
318 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell319 {action}
320 </div>
321 <div className="space-y-px">{children}</div>
322 </section>
323 );
324}
325
326function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
327 const current = shell.workspace?.slug;
328 return (
329 <DropdownMenu>
A Vercel-like sidebar and a new mark330 {/* The name goes to the workspace; only the arrows switch it. */}
331 <Link
332 to={current ? `/${current}` : "/workspaces/new"}
333 className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
334 >
Agents as a team: lifecycle, merge queue, billing and a new shell335 {current ? (
A Vercel-like sidebar and a new mark336 <Avatar name={current} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell337 ) : (
A Vercel-like sidebar and a new mark338 <span className="size-5 rounded-md border border-dashed border-line-strong" />
Agents as a team: lifecycle, merge queue, billing and a new shell339 )}
A Vercel-like sidebar and a new mark340 <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
341 {shell.workspace && (
342 <span className="shrink-0 rounded-full bg-raised px-1.5 py-px text-[0.625rem] font-medium text-muted ring-1 ring-line capitalize">
343 {shell.workspace.role}
Agents as a team: lifecycle, merge queue, billing and a new shell344 </span>
A Vercel-like sidebar and a new mark345 )}
346 </Link>
347 <DropdownMenuTrigger
348 aria-label="Switch workspace"
349 className="ml-auto flex h-8 w-6 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"
350 >
351 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell352 </DropdownMenuTrigger>
353 <DropdownMenuContent align="start" className="w-60">
354 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
355 {(user.workspaces ?? []).map((membership) => (
356 <DropdownMenuItem asChild key={membership.slug}>
357 <Link to={`/${membership.slug}`}>
358 <Avatar name={membership.slug} size={16} square />
359 <span className="grow">{membership.slug}</span>
360 {membership.slug === current && <Check className="text-accent" />}
361 </Link>
362 </DropdownMenuItem>
363 ))}
364 <DropdownMenuSeparator />
365 <DropdownMenuItem asChild>
366 <Link to="/workspaces/new">
367 <Plus />
368 New workspace
369 </Link>
370 </DropdownMenuItem>
371 </DropdownMenuContent>
372 </DropdownMenu>
373 );
374}
375
Billing accounts, terms and enterprises; g1t is no longer free376/**
377 * This month's usage, and how close the workspace is to its usage limit,
378 * as Vercel shows a plan's usage.
379 */
A Vercel-like sidebar and a new mark380function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
Usage while free is shown at cost; agents get rustfmt and clippy381 if (shell.monthUsageMicros == null) return null;
382 const spent = shell.monthUsageMicros;
Billing accounts, terms and enterprises; g1t is no longer free383 const limit = shell.limit;
384 const ceiling = limit?.ceilingMicros ?? null;
385 const share = limit && ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
386 const tone = limit?.state === "stopped" ? "text-danger" : limit?.state === "warning" ? "text-warn" : "text-faint";
387 const bar = limit?.state === "stopped" ? "bg-danger" : limit?.state === "warning" ? "bg-warn" : "bg-accent";
A Vercel-like sidebar and a new mark388 return (
389 <Link
390 to={`/${slug}/-/usage`}
391 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
392 >
393 <span className="flex items-baseline justify-between text-xs">
394 <span className="font-medium text-fg">Usage</span>
395 <span className="text-faint">this month</span>
396 </span>
397 <span className="mt-2 flex items-baseline justify-between">
398 <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 forks399 {shell.free ? (
400 <span className="text-xs text-accent">Free for now</span>
Billing accounts, terms and enterprises; g1t is no longer free401 ) : limit?.comped ? (
402 <span className="text-xs text-accent">Comped</span>
Free while g1t is being built out; agents can check out their own forks403 ) : (
Billing accounts, terms and enterprises; g1t is no longer free404 ceiling != null && (
405 <span className={`text-xs ${tone}`}>
406 {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 forks407 </span>
408 )
A Vercel-like sidebar and a new mark409 )}
410 </span>
Billing accounts, terms and enterprises; g1t is no longer free411 {ceiling != null && !limit?.comped && (
412 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
413 <span
414 className={`block h-full rounded-full ${bar}`}
415 style={{ width: `${Math.max(share * 100, share > 0 ? 3 : 0)}%` }}
416 />
417 </span>
418 )}
A Vercel-like sidebar and a new mark419 </Link>
420 );
421}
422
423function AccountMenu({ user }: { user: User }) {
424 const submit = useSubmit();
425 return (
426 <DropdownMenu>
427 <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">
428 <Avatar name={user.username} size={22} />
429 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
430 <Ellipsis size={15} className="shrink-0 text-faint" />
431 </DropdownMenuTrigger>
432 <DropdownMenuContent align="start" side="top" className="w-56">
433 <DropdownMenuLabel>
434 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
435 </DropdownMenuLabel>
436 <DropdownMenuSeparator />
437 <DropdownMenuItem asChild>
438 <Link to="/settings">
439 <Settings />
440 Your settings
441 </Link>
442 </DropdownMenuItem>
443 <DropdownMenuItem asChild>
444 <a href="https://docs.g1t.sh/">
445 <BookOpen />
446 Documentation
447 </a>
448 </DropdownMenuItem>
449 <DropdownMenuSeparator />
450 {/* Submitted from here: the menu closes on select, and a button
451 that has left the page cannot submit a form. */}
452 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
453 <LogOut />
454 Sign out
455 </DropdownMenuItem>
456 </DropdownMenuContent>
457 </DropdownMenu>
458 );
459}
460
A catalogue of model providers, and settings that feel like settings461/** A workspace's settings pages, which the sidebar slides over to. */
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs462const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
A catalogue of model providers, and settings that feel like settings463
Polish: phones, copy boxes, the plan page, the landing page, a real glide464/**
Sidebar: no settings flash on the way into a repository, and a better slide465 * The sidebar's menus are two layers, the way a phone pushes a screen: the
466 * one arriving slides in from the right over the full width, while the one
467 * leaving drifts a quarter of the way left and fades, both on one long
Sidebar: the panels really slide468 * ease-out, the fade quicker than the move so the two never blur together.
469 * Going back reverses it.
Polish: phones, copy boxes, the plan page, the landing page, a real glide470 */
Sidebar: no settings flash on the way into a repository, and a better slide471const LAYER =
Sidebar: the panels really slide472 "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 slide473/** One menu, filling its layer. */
474const PANEL = "h-full overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings475
476/** A workspace's settings, as the sidebar shows them in place of everything else. */
477function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
478 return (
479 <nav
480 aria-label="Workspace settings"
481 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide482 className={PANEL}
A catalogue of model providers, and settings that feel like settings483 >
484 <Link
485 to={`/${slug}`}
486 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"
487 >
488 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
489 <span className="truncate">{slug}</span>
490 </Link>
491 <SidebarGroup title="Settings">
492 {owner && (
493 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
494 General
495 </SidebarLink>
496 )}
497 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
498 Members
499 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments500 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
501 Access tokens
502 </SidebarLink>
A catalogue of model providers, and settings that feel like settings503 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
Secrets and variables: one list, rows per environment, for workflows and deployments504 Billing and plans
A catalogue of model providers, and settings that feel like settings505 </SidebarLink>
506 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
507 Integrations
Webhooks: every event, to your own addresses, signed and retried508 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs509 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
510 Secrets and variables
511 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments512 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
513 Webhooks
A catalogue of model providers, and settings that feel like settings514 </SidebarLink>
515 </SidebarGroup>
516 </nav>
517 );
518}
519
Sidebar: no settings flash on the way into a repository, and a better slide520/**
521 * What the repository menu needs. Known from the address before the page's
522 * data arrives, so the menu is right from the first frame; the counts fill
523 * in once it does.
524 */
525type MenuRepo = {
526 namespace: string;
527 name: string;
528 member: boolean;
529 issues?: number;
530 pulls?: number;
531};
532
533function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
534 return (
535 a != null &&
536 b != null &&
537 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
538 a.name.toLowerCase() === b.name.toLowerCase()
539 );
540}
A repository has its own sidebar, as settings do541
542/**
Projects: what a workspace builds and runs, first on every page543 * A project's own menu, which the sidebar slides to while you are in it,
544 * as it does for settings: everything about the project, running and its
545 * code, and nothing else, with the way back to everything.
A repository has its own sidebar, as settings do546 */
Sidebar: no settings flash on the way into a repository, and a better slide547function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
A repository has its own sidebar, as settings do548 const base = `/${repo.namespace}/${repo.name}`;
549 return (
550 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
551 <Link
552 to="/"
553 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"
554 >
555 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
556 Mission control
557 </Link>
558 <Link
559 to={base}
560 className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60"
561 >
562 <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 page563 {isPrivate ? <Lock size={13} /> : <Box size={13} />}
A repository has its own sidebar, as settings do564 </span>
565 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
566 <span className="text-faint">{repo.namespace}/</span>
567 <span className="font-semibold text-fg">{repo.name}</span>
568 </span>
569 </Link>
Projects: what a workspace builds and runs, first on every page570 <div className="mt-3 space-y-px">
571 <SidebarLink to={base} end icon={<LayoutGrid size={15} />}>
572 Overview
573 </SidebarLink>
The project menu opens by section, and the workspace has its own panel574
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales575 <SidebarSection
576 title="Work"
577 icon={<Kanban size={15} />}
578 paths={sectionPaths(base, "Work", [`${base}/issues`, `${base}/pulls`, `${base}/pull`, `${base}/queue`, `${base}/plans`])}
579 >
The project menu opens by section, and the workspace has its own panel580 <SidebarSubLink to={`${base}/issues`} count={repo.issues}>
581 Issues
582 </SidebarSubLink>
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales583 <SidebarSubLink to={`${base}/pulls`} also={`${base}/pull`} count={repo.pulls}>
584 Pull requests
585 </SidebarSubLink>
586 <SidebarSubLink to={`${base}/queue`}>Merge queue</SidebarSubLink>
The project menu opens by section, and the workspace has its own panel587 {repo.member && <SidebarSubLink to={`${base}/plans`}>Outcomes</SidebarSubLink>}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales588 <SoonItems base={base} section="Work" />
The project menu opens by section, and the workspace has its own panel589 </SidebarSection>
590
591 <SidebarSection
592 title="Code"
593 icon={<Code2 size={15} />}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales594 paths={sectionPaths(base, "Code", [`${base}/code`, `${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`])}
The project menu opens by section, and the workspace has its own panel595 >
596 <SidebarSubLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`]}>
597 Files
598 </SidebarSubLink>
599 <SidebarSubLink to={`${base}/commits`} also={`${base}/commit`}>
600 Commits
601 </SidebarSubLink>
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales602 <SoonItems base={base} section="Code" />
The project menu opens by section, and the workspace has its own panel603 </SidebarSection>
604
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales605 <SidebarSection title="Agents" icon={<Bot size={15} />} paths={sectionPaths(base, "Agents", [])} soon>
606 <SoonItems base={base} section="Agents" />
The project menu opens by section, and the workspace has its own panel607 </SidebarSection>
608
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales609 <SidebarSection
610 title="Ship"
611 icon={<Rocket size={15} />}
612 paths={sectionPaths(base, "Ship", [`${base}/actions`, ...(repo.member ? [`${base}/deployments`] : [])])}
613 >
614 <SidebarSubLink to={`${base}/actions`}>Workflows</SidebarSubLink>
The project menu opens by section, and the workspace has its own panel615 {repo.member && <SidebarSubLink to={`${base}/deployments`}>Deployments</SidebarSubLink>}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales616 <SoonItems base={base} section="Ship" />
The project menu opens by section, and the workspace has its own panel617 </SidebarSection>
618
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales619 <SidebarSection title="Security" icon={<ShieldCheck size={15} />} paths={sectionPaths(base, "Security", [])} soon>
620 <SoonItems base={base} section="Security" />
The project menu opens by section, and the workspace has its own panel621 </SidebarSection>
622
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales623 <SidebarSection title="Observe" icon={<Activity size={15} />} paths={sectionPaths(base, "Observe", [])} soon>
624 <SoonItems base={base} section="Observe" />
The project menu opens by section, and the workspace has its own panel625 </SidebarSection>
626
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales627 <SidebarSection title="Insights" icon={<BarChart3 size={15} />} paths={sectionPaths(base, "Insights", [])} soon>
628 <SoonItems base={base} section="Insights" />
The project menu opens by section, and the workspace has its own panel629 </SidebarSection>
630
Projects: what a workspace builds and runs, first on every page631 {repo.member && (
The project menu opens by section, and the workspace has its own panel632 <SidebarSection title="Settings" icon={<Settings size={15} />} paths={[`${base}/settings`]}>
633 <SidebarSubLink to={`${base}/settings`} end>
634 General
635 </SidebarSubLink>
636 <SidebarSubLink to={`${base}/settings/repository`}>Repository</SidebarSubLink>
637 <SidebarSubLink to={`${base}/settings/deployments`}>Deployments</SidebarSubLink>
638 <SidebarSubLink to={`${base}/settings/dependencies`}>Dependencies</SidebarSubLink>
639 <SidebarSubLink to={`${base}/settings/secrets`}>Secrets and variables</SidebarSubLink>
640 <SidebarSubLink to={`${base}/settings/webhooks`}>Webhooks</SidebarSubLink>
641 </SidebarSection>
Projects: what a workspace builds and runs, first on every page642 )}
643 </div>
A repository has its own sidebar, as settings do644 </nav>
645 );
646}
647
Account settings in the same shape as a workspace's648/** Your own settings, as the sidebar shows them on the settings page. */
649function AccountSettingsMenu({ open }: { open: boolean }) {
650 const { hash } = useLocation();
651 const item = (id: string, icon: ReactNode, label: string) => (
652 <Link
653 to={`/settings#${id}`}
654 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
655 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
656 }`}
657 >
658 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
659 {label}
660 </Link>
661 );
662 return (
663 <nav
664 aria-label="Your settings"
665 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide666 className={PANEL}
Account settings in the same shape as a workspace's667 >
668 <Link
669 to="/"
670 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"
671 >
672 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
673 Mission control
674 </Link>
675 <SidebarGroup title="Account">
Secrets and variables: one list, rows per environment, for workflows and deployments676 {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")}
677 {item("tokens", <KeyRound size={15} />, "Access tokens")}
Account settings in the same shape as a workspace's678 {item("applications", <Plug size={15} />, "Connected applications")}
679 </SidebarGroup>
680 </nav>
681 );
682}
683
A Vercel-like sidebar and a new mark684function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell685 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings686 const { pathname } = useLocation();
687 const going = useNavigation().location?.pathname;
688 // On a workspace's settings page, or on the way to one, its settings take
689 // the sidebar over.
690 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's691 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do692 const active = shell.repo;
693 // In a repository, or on the way into one, its own menu takes the sidebar.
694 const target = going ?? pathname;
695 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
696 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
697 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
698 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide699 // The repository the menu is for: the one loaded if it is the one being
700 // gone to, else what the address says, at once.
701 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
702 const menuRepo: MenuRepo | null = targetRepo
703 ? sameRepo(active, targetRepo)
704 ? active
705 : {
706 ...targetRepo,
707 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
708 }
709 : null;
A repository has its own sidebar, as settings do710 // What sits on the far side of the track. Kept while sliding back, so it
711 // does not vanish on the way out.
712 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide713 if (inAccount) side.current = "account";
714 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do715 else if (inRepo) side.current = "repo";
716 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide717 const shown = useRef<MenuRepo | null>(menuRepo);
718 if (menuRepo) shown.current = menuRepo;
719 // Moving between two of the far-side menus (settings to a repository,
720 // one repository to another) crossfades in place instead of sliding.
721 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
722 const lastAway = useRef(away);
723 const lastKey = useRef(detailKey);
724 const swapped = lastAway.current && away && lastKey.current !== detailKey;
725 useEffect(() => {
726 lastAway.current = away;
727 lastKey.current = detailKey;
728 });
Agents as a team: lifecycle, merge queue, billing and a new shell729 // The repository being looked at is listed even when it is someone else's.
730 const listed =
731 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
732 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
733 : shell.repos;
734 return (
735 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark736 {/* The same height and rule as the top bar, so the two read as one line. */}
Two limits, real invoices, trust that grows by itself, sales signals737 <div className="flex h-16 shrink-0 items-center gap-2 border-b border-line px-3">
738 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-2 hover:bg-raised">
739 <Mark className="size-6" />
Agents as a team: lifecycle, merge queue, billing and a new shell740 </Link>
A Vercel-like sidebar and a new mark741 <span className="shrink-0 text-line-strong" aria-hidden="true">
742 /
743 </span>
744 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell745 </div>
A Vercel-like sidebar and a new mark746 <div className="px-2 pt-3">
747 <button
748 type="button"
749 onClick={onFind}
Two limits, real invoices, trust that grows by itself, sales signals750 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 mark751 >
752 <Search size={14} />
753 <span className="grow text-left">Find…</span>
754 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
755 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell756 </div>
Sidebar: no settings flash on the way into a repository, and a better slide757 <div className="relative min-h-0 grow overflow-hidden">
758 <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 glide759 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell760 <div className="mt-3 space-y-px">
761 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
762 Mission control
763 </SidebarLink>
764 <SidebarLink to="/explore" icon={<Compass size={15} />}>
765 Explore
766 </SidebarLink>
767 </div>
768
769 <SidebarGroup
Projects: what a workspace builds and runs, first on every page770 title="Projects"
Agents as a team: lifecycle, merge queue, billing and a new shell771 action={
772 ws && (
773 <Link
774 to={`/new?workspace=${ws.slug}`}
Projects: what a workspace builds and runs, first on every page775 aria-label="New project"
Agents as a team: lifecycle, merge queue, billing and a new shell776 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
777 >
778 <Plus size={13} />
779 </Link>
780 )
781 }
782 >
783 {listed.length === 0 && (
784 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
785 )}
786 {listed.map((repo) => {
787 const base = `/${repo.namespace}/${repo.name}`;
788 return (
789 <div key={base}>
790 <SidebarLink
791 to={base}
Projects: what a workspace builds and runs, first on every page792 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
Agents as a team: lifecycle, merge queue, billing and a new shell793 >
Projects: what a workspace builds and runs, first on every page794 <span className="text-[0.8125rem]">
Agents as a team: lifecycle, merge queue, billing and a new shell795 {repo.namespace !== ws?.slug && (
Projects: what a workspace builds and runs, first on every page796 <span className="font-mono text-faint">{repo.namespace}/</span>
Agents as a team: lifecycle, merge queue, billing and a new shell797 )}
Projects: what a workspace builds and runs, first on every page798 {"title" in repo && repo.title ? repo.title : repo.name}
Agents as a team: lifecycle, merge queue, billing and a new shell799 </span>
800 </SidebarLink>
801 </div>
802 );
803 })}
804 </SidebarGroup>
805 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide806 </div>
Sidebar: no settings flash on the way into a repository, and a better slide807 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
808 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
809 {side.current === "repo" && shown.current ? (
810 <RepoMenu
811 repo={shown.current}
812 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
813 open={inRepo}
814 />
815 ) : side.current === "account" || !ws ? (
816 <AccountSettingsMenu open={inAccount} />
817 ) : (
818 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
819 )}
820 </div>
821 </div>
A catalogue of model providers, and settings that feel like settings822 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell823
The project menu opens by section, and the workspace has its own panel824 {/* The workspace's own things sit at the bottom, by its usage and the
825 account: a panel of their own, ruled off from whatever menu is above,
826 however long it grows. */}
827 <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 control828 {ws && (
829 <div className="space-y-px">
The project menu opens by section, and the workspace has its own panel830 <p className="flex items-center gap-2 px-2 pb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-faint">
831 <span>Workspace</span>
832 <span className="min-w-0 truncate font-mono normal-case tracking-normal text-muted">{ws.slug}</span>
833 </p>
Workspace things at the bottom of the sidebar, and on mission control834 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
835 Usage
836 </SidebarLink>
837 {/* Everything else about the workspace lives in its settings, and only there. */}
838 <SidebarLink
839 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
840 icon={<Settings size={15} />}
841 >
842 Settings
843 </SidebarLink>
844 </div>
845 )}
A Vercel-like sidebar and a new mark846 {ws && <UsageCard slug={ws.slug} shell={shell} />}
847 <AccountMenu user={user} />
848 </div>
849 </div>
850 );
851}
852
853/** Words for the sections a path can end in. */
854const SECTIONS: Record<string, string> = {
855 issues: "Issues",
856 pulls: "Pull requests",
857 queue: "Merge queue",
858 commits: "Commits",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales859 plans: "Outcomes",
860 actions: "Workflows",
861 soon: "Soon",
Deployments: a preview for every pull request, production on g1t.page862 deployments: "Deployments",
Projects: what a workspace builds and runs, first on every page863 repository: "Repository",
Project dependencies: addresses, preview stacks, Affects, and agents who know864 dependencies: "Dependencies",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales865 code: "Files",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs866 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark867 settings: "Settings",
A catalogue of model providers, and settings that feel like settings868 people: "Members",
A Vercel-like sidebar and a new mark869 tokens: "Access tokens",
870 usage: "Usage",
Secrets and variables: one list, rows per environment, for workflows and deployments871 billing: "Billing and plans",
A catalogue of model providers, and settings that feel like settings872 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried873 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark874 tree: "Code",
875 blob: "Code",
876};
877
878/** Where the page is, as a trail of links: workspace / repository / section. */
879function Breadcrumbs({ pathname }: { pathname: string }) {
880 const parts = pathname.split("/").filter(Boolean);
881 const reserved = ["settings", "explore", "new", "search", "workspaces"];
882 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
883 if (reserved.includes(parts[0]!)) {
884 const words: Record<string, string> = {
Account settings in the same shape as a workspace's885 settings: "Account",
A Vercel-like sidebar and a new mark886 explore: "Explore",
Projects live: fixes from walking them end to end887 new: "New project",
A Vercel-like sidebar and a new mark888 search: "Search",
889 workspaces: "New workspace",
890 };
891 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
892 }
893 const [owner, second, third, fourth] = parts;
894 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
895 if (second === "-") {
A catalogue of model providers, and settings that feel like settings896 const page = `/${owner}/-/${third}`;
897 if (third && SETTINGS_PAGE.test(page)) {
898 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
899 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
900 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark901 } else if (second) {
902 const repo = `/${owner}/${second}`;
903 trail.push({ label: second, to: repo, mono: true });
904 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
905 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
906 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 deployments907 else if (third && SECTIONS[third]) {
908 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
909 // A settings page names which one: Settings / Secrets and variables.
910 if (third === "settings" && fourth && SECTIONS[fourth]) {
911 trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
912 }
913 }
A Vercel-like sidebar and a new mark914 }
915 return (
916 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
917 {trail.map((crumb, index) => (
918 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
919 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell920 <Link
A Vercel-like sidebar and a new mark921 to={crumb.to}
922 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
923 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
924 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell925 >
A Vercel-like sidebar and a new mark926 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell927 </Link>
A Vercel-like sidebar and a new mark928 </span>
929 ))}
930 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell931 );
932}
933
934type Command = { label: string; hint?: string; to: string; icon: ReactNode };
935
936/** Everything the palette can jump to, from what the sidebar already knows. */
937function commandsFor(user: User, shell: ShellData): Command[] {
938 const commands: Command[] = [
939 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
940 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
Projects: what a workspace builds and runs, first on every page941 { label: "New project", to: "/new", icon: <Plus size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell942 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
943 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
944 ];
945 const repo = shell.repo;
946 if (repo) {
947 const base = `/${repo.namespace}/${repo.name}`;
948 const name = `${repo.namespace}/${repo.name}`;
949 commands.unshift(
Projects: what a workspace builds and runs, first on every page950 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
951 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell952 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
953 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
954 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
955 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
956 ...(repo.member
957 ? [
958 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
Projects: what a workspace builds and runs, first on every page959 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
960 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
961 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
962 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell963 ]
964 : []),
965 );
966 }
967 for (const membership of user.workspaces ?? []) {
968 commands.push(
969 { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request970 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
Projects: what a workspace builds and runs, first on every page971 { 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 settings972 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
973 { 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 shell974 );
975 }
976 for (const listed of shell.repos) {
977 commands.push({
Projects: what a workspace builds and runs, first on every page978 label: listed.title ?? listed.name,
979 hint: `${listed.namespace} · Project`,
Agents as a team: lifecycle, merge queue, billing and a new shell980 to: `/${listed.namespace}/${listed.name}`,
Projects: what a workspace builds and runs, first on every page981 icon: <Box size={15} />,
Agents as a team: lifecycle, merge queue, billing and a new shell982 });
983 }
984 return commands;
985}
986
987function CommandPalette({
988 open,
989 onClose,
990 commands,
991}: {
992 open: boolean;
993 onClose: () => void;
994 commands: Command[];
995}) {
996 const navigate = useNavigate();
997 const [query, setQuery] = useState("");
998 const [selected, setSelected] = useState(0);
999 const input = useRef<HTMLInputElement>(null);
1000 const matches = useMemo(() => {
1001 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
1002 return commands
1003 .filter((command) => {
1004 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
1005 return words.every((word) => text.includes(word));
1006 })
1007 .slice(0, 12);
1008 }, [commands, query]);
1009
1010 useEffect(() => {
1011 if (!open) return;
1012 setQuery("");
1013 setSelected(0);
1014 input.current?.focus();
1015 }, [open]);
1016 useEffect(() => setSelected(0), [query]);
1017
1018 if (!open) return null;
1019 const go = (command: Command | undefined) => {
1020 if (!command) return;
1021 onClose();
1022 navigate(command.to);
1023 };
1024 return (
1025 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
1026 <div
1027 role="dialog"
1028 aria-label="Go to"
1029 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
1030 onClick={(event) => event.stopPropagation()}
1031 >
1032 <div className="flex items-center gap-3 border-b border-line px-4">
1033 <Search size={16} className="text-faint" />
1034 <input
1035 ref={input}
1036 value={query}
1037 onChange={(event) => setQuery(event.target.value)}
1038 onKeyDown={(event) => {
1039 if (event.key === "ArrowDown") {
1040 event.preventDefault();
1041 setSelected((index) => Math.min(index + 1, matches.length - 1));
1042 } else if (event.key === "ArrowUp") {
1043 event.preventDefault();
1044 setSelected((index) => Math.max(index - 1, 0));
1045 } else if (event.key === "Enter") {
1046 event.preventDefault();
1047 go(matches[selected]);
1048 } else if (event.key === "Escape") {
1049 onClose();
1050 }
1051 }}
1052 placeholder="Go to a repository, a page, or an action…"
1053 autoComplete="off"
1054 data-1p-ignore
1055 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"
1056 />
1057 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
1058 </div>
1059 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
1060 {matches.length === 0 && (
1061 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
1062 )}
1063 {matches.map((command, index) => (
1064 <li key={`${command.to}-${command.label}`}>
1065 <button
1066 type="button"
1067 onMouseEnter={() => setSelected(index)}
1068 onClick={() => go(command)}
1069 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
1070 index === selected ? "bg-line text-fg" : "text-muted"
1071 }`}
1072 >
1073 <span className="shrink-0 text-faint">{command.icon}</span>
1074 <span className="min-w-0 grow truncate">{command.label}</span>
1075 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
1076 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
1077 </button>
1078 </li>
1079 ))}
1080 </ul>
1081 </div>
1082 </div>
1083 );
1084}
1085
1086/**
1087 * A bar across the top of the page while the next one loads, as GitHub
1088 * has: it appears at once, creeps towards the end while waiting, then fills
1089 * and fades when the page arrives.
1090 */
1091export function Progress() {
1092 const navigation = useNavigation();
1093 const busy = navigation.state !== "idle";
1094 const [width, setWidth] = useState(0);
1095 const [visible, setVisible] = useState(false);
1096 useEffect(() => {
1097 if (busy) {
1098 setVisible(true);
1099 setWidth(12);
1100 // Slows as it goes, never quite arriving until the page does.
1101 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
1102 return () => clearInterval(timer);
1103 }
1104 setWidth((now) => (now > 0 ? 100 : 0));
1105 const fade = setTimeout(() => setVisible(false), 250);
1106 const reset = setTimeout(() => setWidth(0), 550);
1107 return () => {
1108 clearTimeout(fade);
1109 clearTimeout(reset);
1110 };
1111 }, [busy]);
1112 return (
1113 <div
1114 aria-hidden="true"
1115 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
1116 visible ? "opacity-100" : "opacity-0"
1117 }`}
1118 >
1119 <div
1120 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
1121 style={{ width: `${width}%` }}
1122 />
1123 </div>
1124 );
1125}
1126
1127/**
1128 * The signed-in app: a sidebar with the workspace, its repositories and the
1129 * sections of the one being looked at; a slim bar with search and the
1130 * account; and the page.
1131 */
1132export function AppShell({
1133 user,
1134 shell,
1135 banner,
1136 children,
1137}: {
1138 user: User;
1139 shell: ShellData;
1140 banner?: ReactNode;
1141 children: ReactNode;
1142}) {
1143 const submit = useSubmit();
1144 const { pathname } = useLocation();
1145 const [drawer, setDrawer] = useState(false);
1146 const [palette, setPalette] = useState(false);
1147 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
1148
1149 // A new page closes the drawer on small screens.
1150 useEffect(() => setDrawer(false), [pathname]);
1151 useEffect(() => {
1152 const onKey = (event: KeyboardEvent) => {
1153 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
1154 event.preventDefault();
1155 setPalette((open) => !open);
1156 }
1157 };
1158 window.addEventListener("keydown", onKey);
1159 return () => window.removeEventListener("keydown", onKey);
1160 }, []);
1161
1162 return (
1163 <div className="min-h-screen">
1164 <Progress />
1165 <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 mark1166 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1167 </aside>
1168 {drawer && (
1169 <div className="fixed inset-0 z-50 lg:hidden">
1170 <button
1171 type="button"
1172 aria-label="Close menu"
1173 className="absolute inset-0 bg-black/60"
1174 onClick={() => setDrawer(false)}
1175 />
1176 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
1177 <button
1178 type="button"
1179 aria-label="Close menu"
1180 onClick={() => setDrawer(false)}
1181 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
1182 >
1183 <X size={16} />
1184 </button>
A Vercel-like sidebar and a new mark1185 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1186 </aside>
1187 </div>
1188 )}
1189
Polish: phones, copy boxes, the plan page, the landing page, a real glide1190 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
Two limits, real invoices, trust that grows by itself, sales signals1191 <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 shell1192 <button
1193 type="button"
1194 aria-label="Open menu"
1195 onClick={() => setDrawer(true)}
1196 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
1197 >
1198 <Menu size={18} />
1199 </button>
A Vercel-like sidebar and a new mark1200 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell1201 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark1202 <a
1203 href="https://docs.g1t.sh/"
Two limits, real invoices, trust that grows by itself, sales signals1204 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 mark1205 >
1206 Docs
1207 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell1208 <DropdownMenu>
1209 <DropdownMenuTrigger
1210 aria-label="Create"
Two limits, real invoices, trust that grows by itself, sales signals1211 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 shell1212 >
A Vercel-like sidebar and a new mark1213 <Plus size={14} />
1214 New
Agents as a team: lifecycle, merge queue, billing and a new shell1215 </DropdownMenuTrigger>
1216 <DropdownMenuContent align="end">
1217 {shell.repo && (
1218 <DropdownMenuItem asChild>
1219 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
1220 <CircleDot />
1221 New issue
1222 </Link>
1223 </DropdownMenuItem>
1224 )}
1225 {shell.repo?.member && (
1226 <DropdownMenuItem asChild>
1227 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
1228 <ListTree />
1229 Plan work
1230 </Link>
1231 </DropdownMenuItem>
1232 )}
1233 <DropdownMenuItem asChild>
1234 <Link to="/new">
Projects: what a workspace builds and runs, first on every page1235 <Box />
1236 New project
Agents as a team: lifecycle, merge queue, billing and a new shell1237 </Link>
1238 </DropdownMenuItem>
1239 <DropdownMenuItem asChild>
1240 <Link to="/workspaces/new">
1241 <Users />
1242 New workspace
1243 </Link>
1244 </DropdownMenuItem>
1245 </DropdownMenuContent>
1246 </DropdownMenu>
1247 </div>
1248 </header>
1249 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide1250 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell1251 </div>
1252 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
1253 </div>
1254 );
1255}