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,136 lines44,812 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,
A flat project menu with Code first, views as tabs, and Stripe for every owner3 GanttChart,
4 KanbanSquare,
5 Package,
6 Sparkles,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request7 BarChart3,
Agents as a team: lifecycle, merge queue, billing and a new shell8 BookMarked,
9 BookOpen,
10 Check,
11 ChevronsUpDown,
A Vercel-like sidebar and a new mark12 Ellipsis,
Agents as a team: lifecycle, merge queue, billing and a new shell13 CircleDot,
14 Code2,
15 Compass,
16 CornerDownLeft,
17 CreditCard,
18 GitPullRequest,
19 History,
Secrets and variables: one list, rows per environment, for workflows and deployments20 Fingerprint,
Agents as a team: lifecycle, merge queue, billing and a new shell21 KeyRound,
Projects: what a workspace builds and runs, first on every page22 Box,
Agents as a team: lifecycle, merge queue, billing and a new shell23 LayoutDashboard,
Projects: what a workspace builds and runs, first on every page24 LayoutGrid,
Agents as a team: lifecycle, merge queue, billing and a new shell25 ListTree,
26 Lock,
27 LogOut,
28 Menu,
29 Plus,
30 Search,
Integrations: your own model provider, alerts that open issues, tickets agents read31 Plug,
Agents as a team: lifecycle, merge queue, billing and a new shell32 Settings,
33 Users,
Webhooks: every event, to your own addresses, signed and retried34 Webhook,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs35 PlayCircle,
Pull requests say where they are live; the project menu shows what is coming36 Bot,
37 ShieldCheck,
Deployments: a preview for every pull request, production on g1t.page38 Rocket,
Agents as a team: lifecycle, merge queue, billing and a new shell39 X,
40} from "lucide-react";
41import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
42import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
43
Workspace names and icons, and a component kit for every control44import type { Membership, User } from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell45import { MICROS_PER_DOLLAR } from "@g1t/contracts";
46
A Vercel-like sidebar and a new mark47import { Mark } from "./logo";
Agents as a team: lifecycle, merge queue, billing and a new shell48import { Avatar } from "./ui";
49import {
50 DropdownMenu,
51 DropdownMenuContent,
52 DropdownMenuItem,
53 DropdownMenuLabel,
54 DropdownMenuSeparator,
55 DropdownMenuTrigger,
56} from "./ui/dropdown-menu";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales57import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
Agents as a team: lifecycle, merge queue, billing and a new shell58
59/** What the sidebar needs, worked out by the root loader for a signed-in person. */
60export type ShellData = {
61 /** The workspace the sidebar is about: the one being looked at, or their first. */
Workspace names and icons, and a component kit for every control62 workspace: Membership | null;
Projects: what a workspace builds and runs, first on every page63 /** Its projects, by name: `name` is the slug in their address. */
64 repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
65 /** The project being looked at, if any, whoever owns it. */
Agents as a team: lifecycle, merge queue, billing and a new shell66 repo: {
67 namespace: string;
68 name: string;
69 member: boolean;
70 issues: number;
71 pulls: number;
72 } | null;
Billing accounts, terms and enterprises; g1t is no longer free73 /** Where the workspace stands against its usage limit, if billing is on. */
74 limit: {
75 exposureMicros: number;
76 ceilingMicros: number | null;
77 state: "ok" | "warning" | "stopped";
78 comped: boolean;
79 } | null;
Free while g1t is being built out; agents can check out their own forks80 /** Whether g1t charges nothing for now, while it is being built out. */
81 free?: boolean;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request82 /** What its agents have cost since the start of the month. */
Usage while free is shown at cost; agents get rustfmt and clippy83 /** This month's usage: charged, or at cost while g1t is free. */
84 monthUsageMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell85};
86
87function SidebarLink({
88 to,
89 icon,
90 end,
91 count,
92 also,
93 children,
94}: {
95 to: string;
96 icon: ReactNode;
97 end?: boolean;
98 count?: number;
Projects: what a workspace builds and runs, first on every page99 /** Other path prefixes under which this link is the current one. */
100 also?: string | string[];
Agents as a team: lifecycle, merge queue, billing and a new shell101 children: ReactNode;
102}) {
103 const { pathname } = useLocation();
104 return (
105 <NavLink
106 to={to}
107 end={end}
108 prefetch="intent"
109 className={({ isActive, isPending }) => {
Projects: what a workspace builds and runs, first on every page110 const current =
111 isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
A Vercel-like sidebar and a new mark112 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 shell113 current
114 ? "bg-raised font-medium text-fg"
115 : isPending
116 ? "bg-raised/60 text-fg"
117 : "text-muted hover:bg-raised/60 hover:text-fg"
118 }`;
119 }}
120 >
121 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
122 <span className="min-w-0 grow truncate">{children}</span>
123 {count != null && count > 0 && (
124 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
125 {count}
126 </span>
127 )}
128 </NavLink>
129 );
130}
131
Pull requests say where they are live; the project menu shows what is coming132/**
133 * Something a project will have, shown where it will live so people can
134 * see where g1t is going. Not a link: nothing is behind it yet.
135 */
136function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
137 return (
138 <div
139 title={about}
140 aria-disabled="true"
141 className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint"
142 >
143 <span className="shrink-0 opacity-70">{icon}</span>
144 <span className="grow truncate">{children}</span>
145 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
146 Soon
147 </span>
148 </div>
149 );
150}
151
The project menu opens by section, and the workspace has its own panel152/**
A flat project menu with Code first, views as tabs, and Stripe for every owner153 * A page that is coming, as a link to its roadmap page: faint, with Soon,
154 * and marked current on any of its section's Soon pages.
The project menu opens by section, and the workspace has its own panel155 */
A flat project menu with Code first, views as tabs, and Stripe for every owner156function SidebarSoonLink({
The project menu opens by section, and the workspace has its own panel157 to,
158 also,
A flat project menu with Code first, views as tabs, and Stripe for every owner159 icon,
160 about,
The project menu opens by section, and the workspace has its own panel161 children,
162}: {
163 to: string;
A flat project menu with Code first, views as tabs, and Stripe for every owner164 also?: string[];
165 icon: ReactNode;
166 about: string;
The project menu opens by section, and the workspace has its own panel167 children: ReactNode;
168}) {
169 const { pathname } = useLocation();
170 return (
171 <NavLink
172 to={to}
A flat project menu with Code first, views as tabs, and Stripe for every owner173 title={about}
The project menu opens by section, and the workspace has its own panel174 prefetch="intent"
A flat project menu with Code first, views as tabs, and Stripe for every owner175 className={({ isActive }) => {
176 const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
177 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
178 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 panel179 }`;
180 }}
181 >
A flat project menu with Code first, views as tabs, and Stripe for every owner182 <span className="shrink-0 opacity-80">{icon}</span>
The project menu opens by section, and the workspace has its own panel183 <span className="min-w-0 grow truncate">{children}</span>
184 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
185 Soon
186 </span>
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales187 </NavLink>
The project menu opens by section, and the workspace has its own panel188 );
189}
190
A flat project menu with Code first, views as tabs, and Stripe for every owner191/** Icons for what spans projects. */
192const WORKSPACE_ICONS: Record<string, ReactNode> = {
193 board: <KanbanSquare size={15} />,
194 roadmap: <GanttChart size={15} />,
195 packages: <Package size={15} />,
196 fleet: <Bot size={15} />,
197};
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales198
A flat project menu with Code first, views as tabs, and Stripe for every owner199/** The Soon pages of a section, as paths, so its link is current on them. */
200function soonPaths(base: string, section: RoadmapItem["section"]): string[] {
201 return roadmapIn(section).map((item) => `${base}/soon/${item.key}`);
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales202}
203
Agents as a team: lifecycle, merge queue, billing and a new shell204function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
205 return (
A Vercel-like sidebar and a new mark206 <section className="mt-6">
207 <div className="mb-1 flex h-6 items-center justify-between px-2">
208 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell209 {action}
210 </div>
211 <div className="space-y-px">{children}</div>
212 </section>
213 );
214}
215
Workspace names and icons, and a component kit for every control216/** What a workspace is called where people read it: its display name, else its slug. */
217function displayName(membership: Membership): string {
218 return membership.name?.trim() || membership.slug;
219}
220
Agents as a team: lifecycle, merge queue, billing and a new shell221function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
Workspace names and icons, and a component kit for every control222 const workspace = shell.workspace;
223 const current = workspace?.slug;
224 const label = workspace ? displayName(workspace) : "Choose a workspace";
Agents as a team: lifecycle, merge queue, billing and a new shell225 return (
226 <DropdownMenu>
Workspace names and icons, and a component kit for every control227 {/* The name goes to the workspace; only the arrows switch it. The
228 name gets all the room there is, and the whole of it on hover. */}
A Vercel-like sidebar and a new mark229 <Link
230 to={current ? `/${current}` : "/workspaces/new"}
Workspace names and icons, and a component kit for every control231 title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}
232 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 mark233 >
Workspace names and icons, and a component kit for every control234 {workspace ? (
235 <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell236 ) : (
Workspace names and icons, and a component kit for every control237 <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 shell238 )}
Workspace names and icons, and a component kit for every control239 <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
A Vercel-like sidebar and a new mark240 </Link>
241 <DropdownMenuTrigger
242 aria-label="Switch workspace"
Workspace names and icons, and a component kit for every control243 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 mark244 >
245 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell246 </DropdownMenuTrigger>
Workspace names and icons, and a component kit for every control247 <DropdownMenuContent align="start" className="w-64">
Agents as a team: lifecycle, merge queue, billing and a new shell248 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
249 {(user.workspaces ?? []).map((membership) => (
250 <DropdownMenuItem asChild key={membership.slug}>
Workspace names and icons, and a component kit for every control251 <Link to={`/${membership.slug}`} title={displayName(membership)}>
252 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
253 <span className="flex min-w-0 grow flex-col leading-tight">
254 <span className="truncate">{displayName(membership)}</span>
255 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
256 </span>
257 <span className="shrink-0 text-[0.6875rem] text-faint capitalize">{membership.role}</span>
258 {membership.slug === current ? (
259 <Check className="shrink-0 text-accent" />
260 ) : (
261 <span className="size-4 shrink-0" aria-hidden="true" />
262 )}
Agents as a team: lifecycle, merge queue, billing and a new shell263 </Link>
264 </DropdownMenuItem>
265 ))}
266 <DropdownMenuSeparator />
267 <DropdownMenuItem asChild>
268 <Link to="/workspaces/new">
269 <Plus />
270 New workspace
271 </Link>
272 </DropdownMenuItem>
273 </DropdownMenuContent>
274 </DropdownMenu>
275 );
276}
277
Billing accounts, terms and enterprises; g1t is no longer free278/**
279 * This month's usage, and how close the workspace is to its usage limit,
280 * as Vercel shows a plan's usage.
281 */
A Vercel-like sidebar and a new mark282function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
Usage while free is shown at cost; agents get rustfmt and clippy283 if (shell.monthUsageMicros == null) return null;
284 const spent = shell.monthUsageMicros;
Billing accounts, terms and enterprises; g1t is no longer free285 const limit = shell.limit;
286 const ceiling = limit?.ceilingMicros ?? null;
287 const share = limit && ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
288 const tone = limit?.state === "stopped" ? "text-danger" : limit?.state === "warning" ? "text-warn" : "text-faint";
289 const bar = limit?.state === "stopped" ? "bg-danger" : limit?.state === "warning" ? "bg-warn" : "bg-accent";
A Vercel-like sidebar and a new mark290 return (
291 <Link
292 to={`/${slug}/-/usage`}
293 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
294 >
295 <span className="flex items-baseline justify-between text-xs">
296 <span className="font-medium text-fg">Usage</span>
297 <span className="text-faint">this month</span>
298 </span>
299 <span className="mt-2 flex items-baseline justify-between">
300 <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 forks301 {shell.free ? (
302 <span className="text-xs text-accent">Free for now</span>
Billing accounts, terms and enterprises; g1t is no longer free303 ) : limit?.comped ? (
304 <span className="text-xs text-accent">Comped</span>
Free while g1t is being built out; agents can check out their own forks305 ) : (
Billing accounts, terms and enterprises; g1t is no longer free306 ceiling != null && (
307 <span className={`text-xs ${tone}`}>
308 {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 forks309 </span>
310 )
A Vercel-like sidebar and a new mark311 )}
312 </span>
Billing accounts, terms and enterprises; g1t is no longer free313 {ceiling != null && !limit?.comped && (
314 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
315 <span
316 className={`block h-full rounded-full ${bar}`}
317 style={{ width: `${Math.max(share * 100, share > 0 ? 3 : 0)}%` }}
318 />
319 </span>
320 )}
A Vercel-like sidebar and a new mark321 </Link>
322 );
323}
324
325function AccountMenu({ user }: { user: User }) {
326 const submit = useSubmit();
327 return (
328 <DropdownMenu>
329 <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 control330 <Avatar name={user.username} image={user.avatar} size={22} />
A Vercel-like sidebar and a new mark331 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
332 <Ellipsis size={15} className="shrink-0 text-faint" />
333 </DropdownMenuTrigger>
334 <DropdownMenuContent align="start" side="top" className="w-56">
335 <DropdownMenuLabel>
336 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
337 </DropdownMenuLabel>
338 <DropdownMenuSeparator />
339 <DropdownMenuItem asChild>
340 <Link to="/settings">
341 <Settings />
342 Your settings
343 </Link>
344 </DropdownMenuItem>
345 <DropdownMenuItem asChild>
346 <a href="https://docs.g1t.sh/">
347 <BookOpen />
348 Documentation
349 </a>
350 </DropdownMenuItem>
351 <DropdownMenuSeparator />
352 {/* Submitted from here: the menu closes on select, and a button
353 that has left the page cannot submit a form. */}
354 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
355 <LogOut />
356 Sign out
357 </DropdownMenuItem>
358 </DropdownMenuContent>
359 </DropdownMenu>
360 );
361}
362
A catalogue of model providers, and settings that feel like settings363/** A workspace's settings pages, which the sidebar slides over to. */
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs364const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
A catalogue of model providers, and settings that feel like settings365
Polish: phones, copy boxes, the plan page, the landing page, a real glide366/**
Sidebar: no settings flash on the way into a repository, and a better slide367 * The sidebar's menus are two layers, the way a phone pushes a screen: the
368 * one arriving slides in from the right over the full width, while the one
369 * leaving drifts a quarter of the way left and fades, both on one long
Sidebar: the panels really slide370 * ease-out, the fade quicker than the move so the two never blur together.
371 * Going back reverses it.
Polish: phones, copy boxes, the plan page, the landing page, a real glide372 */
Sidebar: no settings flash on the way into a repository, and a better slide373const LAYER =
Sidebar: the panels really slide374 "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 slide375/** One menu, filling its layer. */
376const PANEL = "h-full overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings377
378/** A workspace's settings, as the sidebar shows them in place of everything else. */
379function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
380 return (
381 <nav
382 aria-label="Workspace settings"
383 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide384 className={PANEL}
A catalogue of model providers, and settings that feel like settings385 >
386 <Link
387 to={`/${slug}`}
388 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"
389 >
390 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
391 <span className="truncate">{slug}</span>
392 </Link>
393 <SidebarGroup title="Settings">
394 {owner && (
395 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
396 General
397 </SidebarLink>
398 )}
399 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
400 Members
401 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments402 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
403 Access tokens
404 </SidebarLink>
A catalogue of model providers, and settings that feel like settings405 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
Secrets and variables: one list, rows per environment, for workflows and deployments406 Billing and plans
A catalogue of model providers, and settings that feel like settings407 </SidebarLink>
408 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
409 Integrations
Webhooks: every event, to your own addresses, signed and retried410 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs411 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
412 Secrets and variables
413 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments414 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
415 Webhooks
A catalogue of model providers, and settings that feel like settings416 </SidebarLink>
417 </SidebarGroup>
418 </nav>
419 );
420}
421
Sidebar: no settings flash on the way into a repository, and a better slide422/**
423 * What the repository menu needs. Known from the address before the page's
424 * data arrives, so the menu is right from the first frame; the counts fill
425 * in once it does.
426 */
427type MenuRepo = {
428 namespace: string;
429 name: string;
430 member: boolean;
431 issues?: number;
432 pulls?: number;
433};
434
435function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
436 return (
437 a != null &&
438 b != null &&
439 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
440 a.name.toLowerCase() === b.name.toLowerCase()
441 );
442}
A repository has its own sidebar, as settings do443
444/**
Projects: what a workspace builds and runs, first on every page445 * A project's own menu, which the sidebar slides to while you are in it,
446 * as it does for settings: everything about the project, running and its
447 * code, and nothing else, with the way back to everything.
A repository has its own sidebar, as settings do448 */
Sidebar: no settings flash on the way into a repository, and a better slide449function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
A repository has its own sidebar, as settings do450 const base = `/${repo.namespace}/${repo.name}`;
451 return (
452 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
453 <Link
454 to="/"
455 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"
456 >
457 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
458 Mission control
459 </Link>
A flat project menu with Code first, views as tabs, and Stripe for every owner460 <NavLink
A repository has its own sidebar, as settings do461 to={base}
A flat project menu with Code first, views as tabs, and Stripe for every owner462 end
463 title="Overview"
464 className={({ isActive }) =>
465 `mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
466 }
A repository has its own sidebar, as settings do467 >
468 <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 page469 {isPrivate ? <Lock size={13} /> : <Box size={13} />}
A repository has its own sidebar, as settings do470 </span>
471 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
472 <span className="text-faint">{repo.namespace}/</span>
473 <span className="font-semibold text-fg">{repo.name}</span>
474 </span>
A flat project menu with Code first, views as tabs, and Stripe for every owner475 </NavLink>
476 {/* A project's pages, flat and in the order people use them. A page
477 with more than one view shows them as tabs across its top. */}
Projects: what a workspace builds and runs, first on every page478 <div className="mt-3 space-y-px">
A flat project menu with Code first, views as tabs, and Stripe for every owner479 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
480 Code
481 </SidebarLink>
482 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
483 Issues
484 </SidebarLink>
485 <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
486 Pull requests
487 </SidebarLink>
488 <SidebarSoonLink to={`${base}/soon/agents`} also={soonPaths(base, "Agents")} icon={<Bot size={15} />} about="Every agent at work on this project, its sessions, playbooks and memory.">
489 Agents
490 </SidebarSoonLink>
491 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
492 Workflows
Projects: what a workspace builds and runs, first on every page493 </SidebarLink>
A flat project menu with Code first, views as tabs, and Stripe for every owner494 {repo.member ? (
495 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
496 Deployments
497 </SidebarLink>
498 ) : null}
499 <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.">
500 Security
501 </SidebarSoonLink>
502 <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do.">
503 Insights
504 </SidebarSoonLink>
Projects: what a workspace builds and runs, first on every page505 {repo.member && (
A flat project menu with Code first, views as tabs, and Stripe for every owner506 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
507 Settings
508 </SidebarLink>
Projects: what a workspace builds and runs, first on every page509 )}
510 </div>
A repository has its own sidebar, as settings do511 </nav>
512 );
513}
514
Account settings in the same shape as a workspace's515/** Your own settings, as the sidebar shows them on the settings page. */
516function AccountSettingsMenu({ open }: { open: boolean }) {
517 const { hash } = useLocation();
518 const item = (id: string, icon: ReactNode, label: string) => (
519 <Link
520 to={`/settings#${id}`}
521 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
522 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
523 }`}
524 >
525 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
526 {label}
527 </Link>
528 );
529 return (
530 <nav
531 aria-label="Your settings"
532 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide533 className={PANEL}
Account settings in the same shape as a workspace's534 >
535 <Link
536 to="/"
537 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"
538 >
539 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
540 Mission control
541 </Link>
542 <SidebarGroup title="Account">
Secrets and variables: one list, rows per environment, for workflows and deployments543 {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")}
544 {item("tokens", <KeyRound size={15} />, "Access tokens")}
Account settings in the same shape as a workspace's545 {item("applications", <Plug size={15} />, "Connected applications")}
546 </SidebarGroup>
547 </nav>
548 );
549}
550
A Vercel-like sidebar and a new mark551function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell552 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings553 const { pathname } = useLocation();
554 const going = useNavigation().location?.pathname;
555 // On a workspace's settings page, or on the way to one, its settings take
556 // the sidebar over.
557 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's558 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do559 const active = shell.repo;
560 // In a repository, or on the way into one, its own menu takes the sidebar.
561 const target = going ?? pathname;
562 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
563 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
564 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
565 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide566 // The repository the menu is for: the one loaded if it is the one being
567 // gone to, else what the address says, at once.
568 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
569 const menuRepo: MenuRepo | null = targetRepo
570 ? sameRepo(active, targetRepo)
571 ? active
572 : {
573 ...targetRepo,
574 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
575 }
576 : null;
A repository has its own sidebar, as settings do577 // What sits on the far side of the track. Kept while sliding back, so it
578 // does not vanish on the way out.
579 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide580 if (inAccount) side.current = "account";
581 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do582 else if (inRepo) side.current = "repo";
583 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide584 const shown = useRef<MenuRepo | null>(menuRepo);
585 if (menuRepo) shown.current = menuRepo;
586 // Moving between two of the far-side menus (settings to a repository,
587 // one repository to another) crossfades in place instead of sliding.
588 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
589 const lastAway = useRef(away);
590 const lastKey = useRef(detailKey);
591 const swapped = lastAway.current && away && lastKey.current !== detailKey;
592 useEffect(() => {
593 lastAway.current = away;
594 lastKey.current = detailKey;
595 });
Agents as a team: lifecycle, merge queue, billing and a new shell596 // The repository being looked at is listed even when it is someone else's.
597 const listed =
598 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
599 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
600 : shell.repos;
601 return (
602 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark603 {/* 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 control604 <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
605 <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 signals606 <Mark className="size-6" />
Agents as a team: lifecycle, merge queue, billing and a new shell607 </Link>
A Vercel-like sidebar and a new mark608 <span className="shrink-0 text-line-strong" aria-hidden="true">
609 /
610 </span>
611 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell612 </div>
A Vercel-like sidebar and a new mark613 <div className="px-2 pt-3">
614 <button
615 type="button"
616 onClick={onFind}
Two limits, real invoices, trust that grows by itself, sales signals617 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 mark618 >
619 <Search size={14} />
620 <span className="grow text-left">Find…</span>
621 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
622 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell623 </div>
Sidebar: no settings flash on the way into a repository, and a better slide624 <div className="relative min-h-0 grow overflow-hidden">
625 <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 glide626 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell627 <div className="mt-3 space-y-px">
628 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
629 Mission control
630 </SidebarLink>
631 <SidebarLink to="/explore" icon={<Compass size={15} />}>
632 Explore
633 </SidebarLink>
634 </div>
635
636 <SidebarGroup
Projects: what a workspace builds and runs, first on every page637 title="Projects"
Agents as a team: lifecycle, merge queue, billing and a new shell638 action={
639 ws && (
640 <Link
641 to={`/new?workspace=${ws.slug}`}
Projects: what a workspace builds and runs, first on every page642 aria-label="New project"
Agents as a team: lifecycle, merge queue, billing and a new shell643 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
644 >
645 <Plus size={13} />
646 </Link>
647 )
648 }
649 >
650 {listed.length === 0 && (
651 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
652 )}
653 {listed.map((repo) => {
654 const base = `/${repo.namespace}/${repo.name}`;
655 return (
656 <div key={base}>
657 <SidebarLink
658 to={base}
Projects: what a workspace builds and runs, first on every page659 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
Agents as a team: lifecycle, merge queue, billing and a new shell660 >
Projects: what a workspace builds and runs, first on every page661 <span className="text-[0.8125rem]">
Agents as a team: lifecycle, merge queue, billing and a new shell662 {repo.namespace !== ws?.slug && (
Projects: what a workspace builds and runs, first on every page663 <span className="font-mono text-faint">{repo.namespace}/</span>
Agents as a team: lifecycle, merge queue, billing and a new shell664 )}
Projects: what a workspace builds and runs, first on every page665 {"title" in repo && repo.title ? repo.title : repo.name}
Agents as a team: lifecycle, merge queue, billing and a new shell666 </span>
667 </SidebarLink>
668 </div>
669 );
670 })}
671 </SidebarGroup>
A flat project menu with Code first, views as tabs, and Stripe for every owner672 {ws && (
673 <SidebarGroup title="Across projects">
674 {roadmapIn("Workspace").map((item) => (
675 <SidebarSoonLink
676 key={item.key}
677 to={`/${ws.slug}/-/soon/${item.key}`}
678 icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />}
679 about={item.summary}
680 >
681 {item.title === "Board" ? "Boards" : item.title}
682 </SidebarSoonLink>
683 ))}
684 </SidebarGroup>
685 )}
Agents as a team: lifecycle, merge queue, billing and a new shell686 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide687 </div>
Sidebar: no settings flash on the way into a repository, and a better slide688 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
689 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
690 {side.current === "repo" && shown.current ? (
691 <RepoMenu
692 repo={shown.current}
693 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
694 open={inRepo}
695 />
696 ) : side.current === "account" || !ws ? (
697 <AccountSettingsMenu open={inAccount} />
698 ) : (
699 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
700 )}
701 </div>
702 </div>
A catalogue of model providers, and settings that feel like settings703 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell704
The project menu opens by section, and the workspace has its own panel705 {/* The workspace's own things sit at the bottom, by its usage and the
706 account: a panel of their own, ruled off from whatever menu is above,
707 however long it grows. */}
708 <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 control709 {ws && (
710 <div className="space-y-px">
The project menu opens by section, and the workspace has its own panel711 <p className="flex items-center gap-2 px-2 pb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-faint">
712 <span>Workspace</span>
713 <span className="min-w-0 truncate font-mono normal-case tracking-normal text-muted">{ws.slug}</span>
714 </p>
Workspace things at the bottom of the sidebar, and on mission control715 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
716 Usage
717 </SidebarLink>
718 {/* Everything else about the workspace lives in its settings, and only there. */}
719 <SidebarLink
720 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
721 icon={<Settings size={15} />}
722 >
723 Settings
724 </SidebarLink>
725 </div>
726 )}
A Vercel-like sidebar and a new mark727 {ws && <UsageCard slug={ws.slug} shell={shell} />}
728 <AccountMenu user={user} />
729 </div>
730 </div>
731 );
732}
733
734/** Words for the sections a path can end in. */
735const SECTIONS: Record<string, string> = {
736 issues: "Issues",
737 pulls: "Pull requests",
738 queue: "Merge queue",
739 commits: "Commits",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales740 plans: "Outcomes",
741 actions: "Workflows",
742 soon: "Soon",
Deployments: a preview for every pull request, production on g1t.page743 deployments: "Deployments",
Projects: what a workspace builds and runs, first on every page744 repository: "Repository",
Project dependencies: addresses, preview stacks, Affects, and agents who know745 dependencies: "Dependencies",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales746 code: "Files",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs747 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark748 settings: "Settings",
A catalogue of model providers, and settings that feel like settings749 people: "Members",
A Vercel-like sidebar and a new mark750 tokens: "Access tokens",
751 usage: "Usage",
Secrets and variables: one list, rows per environment, for workflows and deployments752 billing: "Billing and plans",
A catalogue of model providers, and settings that feel like settings753 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried754 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark755 tree: "Code",
756 blob: "Code",
757};
758
759/** Where the page is, as a trail of links: workspace / repository / section. */
760function Breadcrumbs({ pathname }: { pathname: string }) {
761 const parts = pathname.split("/").filter(Boolean);
762 const reserved = ["settings", "explore", "new", "search", "workspaces"];
763 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
764 if (reserved.includes(parts[0]!)) {
765 const words: Record<string, string> = {
Account settings in the same shape as a workspace's766 settings: "Account",
A Vercel-like sidebar and a new mark767 explore: "Explore",
Projects live: fixes from walking them end to end768 new: "New project",
A Vercel-like sidebar and a new mark769 search: "Search",
770 workspaces: "New workspace",
771 };
772 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
773 }
774 const [owner, second, third, fourth] = parts;
775 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
776 if (second === "-") {
A catalogue of model providers, and settings that feel like settings777 const page = `/${owner}/-/${third}`;
778 if (third && SETTINGS_PAGE.test(page)) {
779 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
780 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
781 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark782 } else if (second) {
783 const repo = `/${owner}/${second}`;
784 trail.push({ label: second, to: repo, mono: true });
785 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
786 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
787 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 deployments788 else if (third && SECTIONS[third]) {
789 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
790 // A settings page names which one: Settings / Secrets and variables.
791 if (third === "settings" && fourth && SECTIONS[fourth]) {
792 trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
793 }
794 }
A Vercel-like sidebar and a new mark795 }
796 return (
797 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
798 {trail.map((crumb, index) => (
799 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
800 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell801 <Link
A Vercel-like sidebar and a new mark802 to={crumb.to}
803 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
804 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
805 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell806 >
A Vercel-like sidebar and a new mark807 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell808 </Link>
A Vercel-like sidebar and a new mark809 </span>
810 ))}
811 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell812 );
813}
814
815type Command = { label: string; hint?: string; to: string; icon: ReactNode };
816
817/** Everything the palette can jump to, from what the sidebar already knows. */
818function commandsFor(user: User, shell: ShellData): Command[] {
819 const commands: Command[] = [
820 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
821 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
Projects: what a workspace builds and runs, first on every page822 { label: "New project", to: "/new", icon: <Plus size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell823 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
824 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
825 ];
826 const repo = shell.repo;
827 if (repo) {
828 const base = `/${repo.namespace}/${repo.name}`;
829 const name = `${repo.namespace}/${repo.name}`;
830 commands.unshift(
Projects: what a workspace builds and runs, first on every page831 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
832 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell833 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
834 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
835 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
836 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
837 ...(repo.member
838 ? [
839 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
Projects: what a workspace builds and runs, first on every page840 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
841 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
842 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
843 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell844 ]
845 : []),
846 );
847 }
848 for (const membership of user.workspaces ?? []) {
849 commands.push(
Workspace names and icons, and a component kit for every control850 { 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 request851 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
Projects: what a workspace builds and runs, first on every page852 { 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 settings853 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
854 { 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 shell855 );
856 }
857 for (const listed of shell.repos) {
858 commands.push({
Projects: what a workspace builds and runs, first on every page859 label: listed.title ?? listed.name,
860 hint: `${listed.namespace} · Project`,
Agents as a team: lifecycle, merge queue, billing and a new shell861 to: `/${listed.namespace}/${listed.name}`,
Projects: what a workspace builds and runs, first on every page862 icon: <Box size={15} />,
Agents as a team: lifecycle, merge queue, billing and a new shell863 });
864 }
865 return commands;
866}
867
868function CommandPalette({
869 open,
870 onClose,
871 commands,
872}: {
873 open: boolean;
874 onClose: () => void;
875 commands: Command[];
876}) {
877 const navigate = useNavigate();
878 const [query, setQuery] = useState("");
879 const [selected, setSelected] = useState(0);
880 const input = useRef<HTMLInputElement>(null);
881 const matches = useMemo(() => {
882 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
883 return commands
884 .filter((command) => {
885 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
886 return words.every((word) => text.includes(word));
887 })
888 .slice(0, 12);
889 }, [commands, query]);
890
891 useEffect(() => {
892 if (!open) return;
893 setQuery("");
894 setSelected(0);
895 input.current?.focus();
896 }, [open]);
897 useEffect(() => setSelected(0), [query]);
898
899 if (!open) return null;
900 const go = (command: Command | undefined) => {
901 if (!command) return;
902 onClose();
903 navigate(command.to);
904 };
905 return (
906 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
907 <div
908 role="dialog"
909 aria-label="Go to"
910 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
911 onClick={(event) => event.stopPropagation()}
912 >
913 <div className="flex items-center gap-3 border-b border-line px-4">
914 <Search size={16} className="text-faint" />
915 <input
916 ref={input}
917 value={query}
918 onChange={(event) => setQuery(event.target.value)}
919 onKeyDown={(event) => {
920 if (event.key === "ArrowDown") {
921 event.preventDefault();
922 setSelected((index) => Math.min(index + 1, matches.length - 1));
923 } else if (event.key === "ArrowUp") {
924 event.preventDefault();
925 setSelected((index) => Math.max(index - 1, 0));
926 } else if (event.key === "Enter") {
927 event.preventDefault();
928 go(matches[selected]);
929 } else if (event.key === "Escape") {
930 onClose();
931 }
932 }}
933 placeholder="Go to a repository, a page, or an action…"
934 autoComplete="off"
935 data-1p-ignore
936 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"
937 />
938 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
939 </div>
940 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
941 {matches.length === 0 && (
942 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
943 )}
944 {matches.map((command, index) => (
945 <li key={`${command.to}-${command.label}`}>
946 <button
947 type="button"
948 onMouseEnter={() => setSelected(index)}
949 onClick={() => go(command)}
950 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
951 index === selected ? "bg-line text-fg" : "text-muted"
952 }`}
953 >
954 <span className="shrink-0 text-faint">{command.icon}</span>
955 <span className="min-w-0 grow truncate">{command.label}</span>
956 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
957 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
958 </button>
959 </li>
960 ))}
961 </ul>
962 </div>
963 </div>
964 );
965}
966
967/**
968 * A bar across the top of the page while the next one loads, as GitHub
969 * has: it appears at once, creeps towards the end while waiting, then fills
970 * and fades when the page arrives.
971 */
972export function Progress() {
973 const navigation = useNavigation();
974 const busy = navigation.state !== "idle";
975 const [width, setWidth] = useState(0);
976 const [visible, setVisible] = useState(false);
977 useEffect(() => {
978 if (busy) {
979 setVisible(true);
980 setWidth(12);
981 // Slows as it goes, never quite arriving until the page does.
982 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
983 return () => clearInterval(timer);
984 }
985 setWidth((now) => (now > 0 ? 100 : 0));
986 const fade = setTimeout(() => setVisible(false), 250);
987 const reset = setTimeout(() => setWidth(0), 550);
988 return () => {
989 clearTimeout(fade);
990 clearTimeout(reset);
991 };
992 }, [busy]);
993 return (
994 <div
995 aria-hidden="true"
996 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
997 visible ? "opacity-100" : "opacity-0"
998 }`}
999 >
1000 <div
1001 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
1002 style={{ width: `${width}%` }}
1003 />
1004 </div>
1005 );
1006}
1007
1008/**
1009 * The signed-in app: a sidebar with the workspace, its repositories and the
1010 * sections of the one being looked at; a slim bar with search and the
1011 * account; and the page.
1012 */
1013export function AppShell({
1014 user,
1015 shell,
1016 banner,
1017 children,
1018}: {
1019 user: User;
1020 shell: ShellData;
1021 banner?: ReactNode;
1022 children: ReactNode;
1023}) {
1024 const submit = useSubmit();
1025 const { pathname } = useLocation();
1026 const [drawer, setDrawer] = useState(false);
1027 const [palette, setPalette] = useState(false);
1028 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
1029
1030 // A new page closes the drawer on small screens.
1031 useEffect(() => setDrawer(false), [pathname]);
1032 useEffect(() => {
1033 const onKey = (event: KeyboardEvent) => {
1034 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
1035 event.preventDefault();
1036 setPalette((open) => !open);
1037 }
1038 };
1039 window.addEventListener("keydown", onKey);
1040 return () => window.removeEventListener("keydown", onKey);
1041 }, []);
1042
1043 return (
1044 <div className="min-h-screen">
1045 <Progress />
1046 <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 mark1047 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1048 </aside>
1049 {drawer && (
1050 <div className="fixed inset-0 z-50 lg:hidden">
1051 <button
1052 type="button"
1053 aria-label="Close menu"
1054 className="absolute inset-0 bg-black/60"
1055 onClick={() => setDrawer(false)}
1056 />
1057 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
1058 <button
1059 type="button"
1060 aria-label="Close menu"
1061 onClick={() => setDrawer(false)}
1062 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
1063 >
1064 <X size={16} />
1065 </button>
A Vercel-like sidebar and a new mark1066 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1067 </aside>
1068 </div>
1069 )}
1070
Polish: phones, copy boxes, the plan page, the landing page, a real glide1071 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
Two limits, real invoices, trust that grows by itself, sales signals1072 <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 shell1073 <button
1074 type="button"
1075 aria-label="Open menu"
1076 onClick={() => setDrawer(true)}
1077 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
1078 >
1079 <Menu size={18} />
1080 </button>
A Vercel-like sidebar and a new mark1081 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell1082 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark1083 <a
1084 href="https://docs.g1t.sh/"
Two limits, real invoices, trust that grows by itself, sales signals1085 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 mark1086 >
1087 Docs
1088 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell1089 <DropdownMenu>
1090 <DropdownMenuTrigger
1091 aria-label="Create"
Two limits, real invoices, trust that grows by itself, sales signals1092 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 shell1093 >
A Vercel-like sidebar and a new mark1094 <Plus size={14} />
1095 New
Agents as a team: lifecycle, merge queue, billing and a new shell1096 </DropdownMenuTrigger>
1097 <DropdownMenuContent align="end">
1098 {shell.repo && (
1099 <DropdownMenuItem asChild>
1100 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
1101 <CircleDot />
1102 New issue
1103 </Link>
1104 </DropdownMenuItem>
1105 )}
1106 {shell.repo?.member && (
1107 <DropdownMenuItem asChild>
1108 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
1109 <ListTree />
1110 Plan work
1111 </Link>
1112 </DropdownMenuItem>
1113 )}
1114 <DropdownMenuItem asChild>
1115 <Link to="/new">
Projects: what a workspace builds and runs, first on every page1116 <Box />
1117 New project
Agents as a team: lifecycle, merge queue, billing and a new shell1118 </Link>
1119 </DropdownMenuItem>
1120 <DropdownMenuItem asChild>
1121 <Link to="/workspaces/new">
1122 <Users />
1123 New workspace
1124 </Link>
1125 </DropdownMenuItem>
1126 </DropdownMenuContent>
1127 </DropdownMenu>
1128 </div>
1129 </header>
1130 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide1131 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell1132 </div>
1133 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
1134 </div>
1135 );
1136}