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

1,140 lines45,071 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents as a team: lifecycle, merge queue, billing and a new shell1import {
Observability gets its own page; tabs never overflow2 Activity,
A catalogue of model providers, and settings that feel like settings3 ArrowLeft,
A flat project menu with Code first, views as tabs, and Stripe for every owner4 GanttChart,
5 KanbanSquare,
6 Package,
7 Sparkles,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request8 BarChart3,
Agents as a team: lifecycle, merge queue, billing and a new shell9 BookMarked,
10 BookOpen,
11 Check,
12 ChevronsUpDown,
A Vercel-like sidebar and a new mark13 Ellipsis,
Agents as a team: lifecycle, merge queue, billing and a new shell14 CircleDot,
15 Code2,
16 Compass,
17 CornerDownLeft,
18 CreditCard,
19 GitPullRequest,
20 History,
Secrets and variables: one list, rows per environment, for workflows and deployments21 Fingerprint,
Agents as a team: lifecycle, merge queue, billing and a new shell22 KeyRound,
Projects: what a workspace builds and runs, first on every page23 Box,
Agents as a team: lifecycle, merge queue, billing and a new shell24 LayoutDashboard,
Projects: what a workspace builds and runs, first on every page25 LayoutGrid,
Agents as a team: lifecycle, merge queue, billing and a new shell26 ListTree,
27 Lock,
28 LogOut,
29 Menu,
30 Plus,
31 Search,
Integrations: your own model provider, alerts that open issues, tickets agents read32 Plug,
Agents as a team: lifecycle, merge queue, billing and a new shell33 Settings,
34 Users,
Webhooks: every event, to your own addresses, signed and retried35 Webhook,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs36 PlayCircle,
Pull requests say where they are live; the project menu shows what is coming37 Bot,
38 ShieldCheck,
Deployments: a preview for every pull request, production on g1t.page39 Rocket,
Agents as a team: lifecycle, merge queue, billing and a new shell40 X,
41} from "lucide-react";
42import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
43import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
44
Workspace names and icons, and a component kit for every control45import type { Membership, User } from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell46import { MICROS_PER_DOLLAR } from "@g1t/contracts";
47
A Vercel-like sidebar and a new mark48import { Mark } from "./logo";
Agents as a team: lifecycle, merge queue, billing and a new shell49import { Avatar } from "./ui";
50import {
51 DropdownMenu,
52 DropdownMenuContent,
53 DropdownMenuItem,
54 DropdownMenuLabel,
55 DropdownMenuSeparator,
56 DropdownMenuTrigger,
57} from "./ui/dropdown-menu";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales58import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
Agents as a team: lifecycle, merge queue, billing and a new shell59
60/** What the sidebar needs, worked out by the root loader for a signed-in person. */
61export type ShellData = {
62 /** The workspace the sidebar is about: the one being looked at, or their first. */
Workspace names and icons, and a component kit for every control63 workspace: Membership | null;
Projects: what a workspace builds and runs, first on every page64 /** Its projects, by name: `name` is the slug in their address. */
65 repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
66 /** The project being looked at, if any, whoever owns it. */
Agents as a team: lifecycle, merge queue, billing and a new shell67 repo: {
68 namespace: string;
69 name: string;
70 member: boolean;
71 issues: number;
72 pulls: number;
73 } | null;
Billing accounts, terms and enterprises; g1t is no longer free74 /** Where the workspace stands against its usage limit, if billing is on. */
75 limit: {
76 exposureMicros: number;
77 ceilingMicros: number | null;
78 state: "ok" | "warning" | "stopped";
79 comped: boolean;
80 } | null;
Free while g1t is being built out; agents can check out their own forks81 /** Whether g1t charges nothing for now, while it is being built out. */
82 free?: boolean;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request83 /** What its agents have cost since the start of the month. */
Usage while free is shown at cost; agents get rustfmt and clippy84 /** This month's usage: charged, or at cost while g1t is free. */
85 monthUsageMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell86};
87
88function SidebarLink({
89 to,
90 icon,
91 end,
92 count,
93 also,
94 children,
95}: {
96 to: string;
97 icon: ReactNode;
98 end?: boolean;
99 count?: number;
Projects: what a workspace builds and runs, first on every page100 /** Other path prefixes under which this link is the current one. */
101 also?: string | string[];
Agents as a team: lifecycle, merge queue, billing and a new shell102 children: ReactNode;
103}) {
104 const { pathname } = useLocation();
105 return (
106 <NavLink
107 to={to}
108 end={end}
109 prefetch="intent"
110 className={({ isActive, isPending }) => {
Projects: what a workspace builds and runs, first on every page111 const current =
112 isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
A Vercel-like sidebar and a new mark113 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 shell114 current
115 ? "bg-raised font-medium text-fg"
116 : isPending
117 ? "bg-raised/60 text-fg"
118 : "text-muted hover:bg-raised/60 hover:text-fg"
119 }`;
120 }}
121 >
122 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
123 <span className="min-w-0 grow truncate">{children}</span>
124 {count != null && count > 0 && (
125 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
126 {count}
127 </span>
128 )}
129 </NavLink>
130 );
131}
132
Pull requests say where they are live; the project menu shows what is coming133/**
134 * Something a project will have, shown where it will live so people can
135 * see where g1t is going. Not a link: nothing is behind it yet.
136 */
137function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
138 return (
139 <div
140 title={about}
141 aria-disabled="true"
142 className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint"
143 >
144 <span className="shrink-0 opacity-70">{icon}</span>
145 <span className="grow truncate">{children}</span>
146 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
147 Soon
148 </span>
149 </div>
150 );
151}
152
The project menu opens by section, and the workspace has its own panel153/**
A flat project menu with Code first, views as tabs, and Stripe for every owner154 * A page that is coming, as a link to its roadmap page: faint, with Soon,
155 * and marked current on any of its section's Soon pages.
The project menu opens by section, and the workspace has its own panel156 */
A flat project menu with Code first, views as tabs, and Stripe for every owner157function SidebarSoonLink({
The project menu opens by section, and the workspace has its own panel158 to,
159 also,
A flat project menu with Code first, views as tabs, and Stripe for every owner160 icon,
161 about,
The project menu opens by section, and the workspace has its own panel162 children,
163}: {
164 to: string;
A flat project menu with Code first, views as tabs, and Stripe for every owner165 also?: string[];
166 icon: ReactNode;
167 about: string;
The project menu opens by section, and the workspace has its own panel168 children: ReactNode;
169}) {
170 const { pathname } = useLocation();
171 return (
172 <NavLink
173 to={to}
A flat project menu with Code first, views as tabs, and Stripe for every owner174 title={about}
The project menu opens by section, and the workspace has its own panel175 prefetch="intent"
A flat project menu with Code first, views as tabs, and Stripe for every owner176 className={({ isActive }) => {
177 const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
178 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
179 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 panel180 }`;
181 }}
182 >
A flat project menu with Code first, views as tabs, and Stripe for every owner183 <span className="shrink-0 opacity-80">{icon}</span>
The project menu opens by section, and the workspace has its own panel184 <span className="min-w-0 grow truncate">{children}</span>
185 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
186 Soon
187 </span>
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales188 </NavLink>
The project menu opens by section, and the workspace has its own panel189 );
190}
191
A flat project menu with Code first, views as tabs, and Stripe for every owner192/** Icons for what spans projects. */
193const WORKSPACE_ICONS: Record<string, ReactNode> = {
194 board: <KanbanSquare size={15} />,
195 roadmap: <GanttChart size={15} />,
196 packages: <Package size={15} />,
197 fleet: <Bot size={15} />,
198};
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales199
A flat project menu with Code first, views as tabs, and Stripe for every owner200/** The Soon pages of a section, as paths, so its link is current on them. */
201function soonPaths(base: string, section: RoadmapItem["section"]): string[] {
202 return roadmapIn(section).map((item) => `${base}/soon/${item.key}`);
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales203}
204
Agents as a team: lifecycle, merge queue, billing and a new shell205function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
206 return (
A Vercel-like sidebar and a new mark207 <section className="mt-6">
208 <div className="mb-1 flex h-6 items-center justify-between px-2">
209 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell210 {action}
211 </div>
212 <div className="space-y-px">{children}</div>
213 </section>
214 );
215}
216
Workspace names and icons, and a component kit for every control217/** What a workspace is called where people read it: its display name, else its slug. */
218function displayName(membership: Membership): string {
219 return membership.name?.trim() || membership.slug;
220}
221
Agents as a team: lifecycle, merge queue, billing and a new shell222function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
Workspace names and icons, and a component kit for every control223 const workspace = shell.workspace;
224 const current = workspace?.slug;
225 const label = workspace ? displayName(workspace) : "Choose a workspace";
Agents as a team: lifecycle, merge queue, billing and a new shell226 return (
227 <DropdownMenu>
Workspace names and icons, and a component kit for every control228 {/* The name goes to the workspace; only the arrows switch it. The
229 name gets all the room there is, and the whole of it on hover. */}
A Vercel-like sidebar and a new mark230 <Link
231 to={current ? `/${current}` : "/workspaces/new"}
Workspace names and icons, and a component kit for every control232 title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}
233 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 mark234 >
Workspace names and icons, and a component kit for every control235 {workspace ? (
236 <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell237 ) : (
Workspace names and icons, and a component kit for every control238 <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 shell239 )}
Workspace names and icons, and a component kit for every control240 <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
A Vercel-like sidebar and a new mark241 </Link>
242 <DropdownMenuTrigger
243 aria-label="Switch workspace"
Workspace names and icons, and a component kit for every control244 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 mark245 >
246 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell247 </DropdownMenuTrigger>
Workspace names and icons, and a component kit for every control248 <DropdownMenuContent align="start" className="w-64">
Agents as a team: lifecycle, merge queue, billing and a new shell249 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
250 {(user.workspaces ?? []).map((membership) => (
251 <DropdownMenuItem asChild key={membership.slug}>
Workspace names and icons, and a component kit for every control252 <Link to={`/${membership.slug}`} title={displayName(membership)}>
253 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
254 <span className="flex min-w-0 grow flex-col leading-tight">
255 <span className="truncate">{displayName(membership)}</span>
256 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
257 </span>
258 <span className="shrink-0 text-[0.6875rem] text-faint capitalize">{membership.role}</span>
259 {membership.slug === current ? (
260 <Check className="shrink-0 text-accent" />
261 ) : (
262 <span className="size-4 shrink-0" aria-hidden="true" />
263 )}
Agents as a team: lifecycle, merge queue, billing and a new shell264 </Link>
265 </DropdownMenuItem>
266 ))}
267 <DropdownMenuSeparator />
268 <DropdownMenuItem asChild>
269 <Link to="/workspaces/new">
270 <Plus />
271 New workspace
272 </Link>
273 </DropdownMenuItem>
274 </DropdownMenuContent>
275 </DropdownMenu>
276 );
277}
278
Billing accounts, terms and enterprises; g1t is no longer free279/**
280 * This month's usage, and how close the workspace is to its usage limit,
281 * as Vercel shows a plan's usage.
282 */
A Vercel-like sidebar and a new mark283function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
Usage while free is shown at cost; agents get rustfmt and clippy284 if (shell.monthUsageMicros == null) return null;
285 const spent = shell.monthUsageMicros;
Billing accounts, terms and enterprises; g1t is no longer free286 const limit = shell.limit;
287 const ceiling = limit?.ceilingMicros ?? null;
288 const share = limit && ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
289 const tone = limit?.state === "stopped" ? "text-danger" : limit?.state === "warning" ? "text-warn" : "text-faint";
290 const bar = limit?.state === "stopped" ? "bg-danger" : limit?.state === "warning" ? "bg-warn" : "bg-accent";
A Vercel-like sidebar and a new mark291 return (
292 <Link
293 to={`/${slug}/-/usage`}
294 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
295 >
296 <span className="flex items-baseline justify-between text-xs">
297 <span className="font-medium text-fg">Usage</span>
298 <span className="text-faint">this month</span>
299 </span>
300 <span className="mt-2 flex items-baseline justify-between">
301 <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 forks302 {shell.free ? (
303 <span className="text-xs text-accent">Free for now</span>
Billing accounts, terms and enterprises; g1t is no longer free304 ) : limit?.comped ? (
305 <span className="text-xs text-accent">Comped</span>
Free while g1t is being built out; agents can check out their own forks306 ) : (
Billing accounts, terms and enterprises; g1t is no longer free307 ceiling != null && (
308 <span className={`text-xs ${tone}`}>
309 {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 forks310 </span>
311 )
A Vercel-like sidebar and a new mark312 )}
313 </span>
Billing accounts, terms and enterprises; g1t is no longer free314 {ceiling != null && !limit?.comped && (
315 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
316 <span
317 className={`block h-full rounded-full ${bar}`}
318 style={{ width: `${Math.max(share * 100, share > 0 ? 3 : 0)}%` }}
319 />
320 </span>
321 )}
A Vercel-like sidebar and a new mark322 </Link>
323 );
324}
325
326function AccountMenu({ user }: { user: User }) {
327 const submit = useSubmit();
328 return (
329 <DropdownMenu>
330 <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 control331 <Avatar name={user.username} image={user.avatar} size={22} />
A Vercel-like sidebar and a new mark332 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
333 <Ellipsis size={15} className="shrink-0 text-faint" />
334 </DropdownMenuTrigger>
335 <DropdownMenuContent align="start" side="top" className="w-56">
336 <DropdownMenuLabel>
337 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
338 </DropdownMenuLabel>
339 <DropdownMenuSeparator />
340 <DropdownMenuItem asChild>
341 <Link to="/settings">
342 <Settings />
343 Your settings
344 </Link>
345 </DropdownMenuItem>
346 <DropdownMenuItem asChild>
347 <a href="https://docs.g1t.sh/">
348 <BookOpen />
349 Documentation
350 </a>
351 </DropdownMenuItem>
352 <DropdownMenuSeparator />
353 {/* Submitted from here: the menu closes on select, and a button
354 that has left the page cannot submit a form. */}
355 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
356 <LogOut />
357 Sign out
358 </DropdownMenuItem>
359 </DropdownMenuContent>
360 </DropdownMenu>
361 );
362}
363
A catalogue of model providers, and settings that feel like settings364/** A workspace's settings pages, which the sidebar slides over to. */
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs365const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
A catalogue of model providers, and settings that feel like settings366
Polish: phones, copy boxes, the plan page, the landing page, a real glide367/**
Sidebar: no settings flash on the way into a repository, and a better slide368 * The sidebar's menus are two layers, the way a phone pushes a screen: the
369 * one arriving slides in from the right over the full width, while the one
370 * leaving drifts a quarter of the way left and fades, both on one long
Sidebar: the panels really slide371 * ease-out, the fade quicker than the move so the two never blur together.
372 * Going back reverses it.
Polish: phones, copy boxes, the plan page, the landing page, a real glide373 */
Sidebar: no settings flash on the way into a repository, and a better slide374const LAYER =
Sidebar: the panels really slide375 "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 slide376/** One menu, filling its layer. */
377const PANEL = "h-full overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings378
379/** A workspace's settings, as the sidebar shows them in place of everything else. */
380function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
381 return (
382 <nav
383 aria-label="Workspace settings"
384 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide385 className={PANEL}
A catalogue of model providers, and settings that feel like settings386 >
387 <Link
388 to={`/${slug}`}
389 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"
390 >
391 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
392 <span className="truncate">{slug}</span>
393 </Link>
394 <SidebarGroup title="Settings">
395 {owner && (
396 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
397 General
398 </SidebarLink>
399 )}
400 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
401 Members
402 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments403 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
404 Access tokens
405 </SidebarLink>
A catalogue of model providers, and settings that feel like settings406 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
Secrets and variables: one list, rows per environment, for workflows and deployments407 Billing and plans
A catalogue of model providers, and settings that feel like settings408 </SidebarLink>
409 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
410 Integrations
Webhooks: every event, to your own addresses, signed and retried411 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs412 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
413 Secrets and variables
414 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments415 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
416 Webhooks
A catalogue of model providers, and settings that feel like settings417 </SidebarLink>
418 </SidebarGroup>
419 </nav>
420 );
421}
422
Sidebar: no settings flash on the way into a repository, and a better slide423/**
424 * What the repository menu needs. Known from the address before the page's
425 * data arrives, so the menu is right from the first frame; the counts fill
426 * in once it does.
427 */
428type MenuRepo = {
429 namespace: string;
430 name: string;
431 member: boolean;
432 issues?: number;
433 pulls?: number;
434};
435
436function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
437 return (
438 a != null &&
439 b != null &&
440 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
441 a.name.toLowerCase() === b.name.toLowerCase()
442 );
443}
A repository has its own sidebar, as settings do444
445/**
Projects: what a workspace builds and runs, first on every page446 * A project's own menu, which the sidebar slides to while you are in it,
447 * as it does for settings: everything about the project, running and its
448 * code, and nothing else, with the way back to everything.
A repository has its own sidebar, as settings do449 */
Sidebar: no settings flash on the way into a repository, and a better slide450function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
A repository has its own sidebar, as settings do451 const base = `/${repo.namespace}/${repo.name}`;
452 return (
453 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
454 <Link
455 to="/"
456 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"
457 >
458 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
459 Mission control
460 </Link>
A flat project menu with Code first, views as tabs, and Stripe for every owner461 <NavLink
A repository has its own sidebar, as settings do462 to={base}
A flat project menu with Code first, views as tabs, and Stripe for every owner463 end
464 title="Overview"
465 className={({ isActive }) =>
466 `mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
467 }
A repository has its own sidebar, as settings do468 >
469 <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 page470 {isPrivate ? <Lock size={13} /> : <Box size={13} />}
A repository has its own sidebar, as settings do471 </span>
472 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
473 <span className="text-faint">{repo.namespace}/</span>
474 <span className="font-semibold text-fg">{repo.name}</span>
475 </span>
A flat project menu with Code first, views as tabs, and Stripe for every owner476 </NavLink>
477 {/* A project's pages, flat and in the order people use them. A page
478 with more than one view shows them as tabs across its top. */}
Projects: what a workspace builds and runs, first on every page479 <div className="mt-3 space-y-px">
A flat project menu with Code first, views as tabs, and Stripe for every owner480 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
481 Code
482 </SidebarLink>
483 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
484 Issues
485 </SidebarLink>
486 <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
487 Pull requests
488 </SidebarLink>
489 <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.">
490 Agents
491 </SidebarSoonLink>
492 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
493 Workflows
Projects: what a workspace builds and runs, first on every page494 </SidebarLink>
A flat project menu with Code first, views as tabs, and Stripe for every owner495 {repo.member ? (
496 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
497 Deployments
498 </SidebarLink>
499 ) : null}
Observability gets its own page; tabs never overflow500 <SidebarSoonLink to={`${base}/soon/logs`} also={soonPaths(base, "Observability")} icon={<Activity size={15} />} about="Logs, errors, uptime and analytics of the project's deployed apps.">
501 Observability
502 </SidebarSoonLink>
A flat project menu with Code first, views as tabs, and Stripe for every owner503 <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.">
504 Security
505 </SidebarSoonLink>
506 <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do.">
507 Insights
508 </SidebarSoonLink>
Projects: what a workspace builds and runs, first on every page509 {repo.member && (
A flat project menu with Code first, views as tabs, and Stripe for every owner510 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
511 Settings
512 </SidebarLink>
Projects: what a workspace builds and runs, first on every page513 )}
514 </div>
A repository has its own sidebar, as settings do515 </nav>
516 );
517}
518
Account settings in the same shape as a workspace's519/** Your own settings, as the sidebar shows them on the settings page. */
520function AccountSettingsMenu({ open }: { open: boolean }) {
521 const { hash } = useLocation();
522 const item = (id: string, icon: ReactNode, label: string) => (
523 <Link
524 to={`/settings#${id}`}
525 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
526 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
527 }`}
528 >
529 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
530 {label}
531 </Link>
532 );
533 return (
534 <nav
535 aria-label="Your settings"
536 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide537 className={PANEL}
Account settings in the same shape as a workspace's538 >
539 <Link
540 to="/"
541 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"
542 >
543 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
544 Mission control
545 </Link>
546 <SidebarGroup title="Account">
Secrets and variables: one list, rows per environment, for workflows and deployments547 {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")}
548 {item("tokens", <KeyRound size={15} />, "Access tokens")}
Account settings in the same shape as a workspace's549 {item("applications", <Plug size={15} />, "Connected applications")}
550 </SidebarGroup>
551 </nav>
552 );
553}
554
A Vercel-like sidebar and a new mark555function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell556 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings557 const { pathname } = useLocation();
558 const going = useNavigation().location?.pathname;
559 // On a workspace's settings page, or on the way to one, its settings take
560 // the sidebar over.
561 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's562 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do563 const active = shell.repo;
564 // In a repository, or on the way into one, its own menu takes the sidebar.
565 const target = going ?? pathname;
566 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
567 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
568 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
569 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide570 // The repository the menu is for: the one loaded if it is the one being
571 // gone to, else what the address says, at once.
572 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
573 const menuRepo: MenuRepo | null = targetRepo
574 ? sameRepo(active, targetRepo)
575 ? active
576 : {
577 ...targetRepo,
578 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
579 }
580 : null;
A repository has its own sidebar, as settings do581 // What sits on the far side of the track. Kept while sliding back, so it
582 // does not vanish on the way out.
583 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide584 if (inAccount) side.current = "account";
585 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do586 else if (inRepo) side.current = "repo";
587 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide588 const shown = useRef<MenuRepo | null>(menuRepo);
589 if (menuRepo) shown.current = menuRepo;
590 // Moving between two of the far-side menus (settings to a repository,
591 // one repository to another) crossfades in place instead of sliding.
592 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
593 const lastAway = useRef(away);
594 const lastKey = useRef(detailKey);
595 const swapped = lastAway.current && away && lastKey.current !== detailKey;
596 useEffect(() => {
597 lastAway.current = away;
598 lastKey.current = detailKey;
599 });
Agents as a team: lifecycle, merge queue, billing and a new shell600 // The repository being looked at is listed even when it is someone else's.
601 const listed =
602 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
603 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
604 : shell.repos;
605 return (
606 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark607 {/* 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 control608 <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
609 <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 signals610 <Mark className="size-6" />
Agents as a team: lifecycle, merge queue, billing and a new shell611 </Link>
A Vercel-like sidebar and a new mark612 <span className="shrink-0 text-line-strong" aria-hidden="true">
613 /
614 </span>
615 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell616 </div>
A Vercel-like sidebar and a new mark617 <div className="px-2 pt-3">
618 <button
619 type="button"
620 onClick={onFind}
Two limits, real invoices, trust that grows by itself, sales signals621 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 mark622 >
623 <Search size={14} />
624 <span className="grow text-left">Find…</span>
625 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
626 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell627 </div>
Sidebar: no settings flash on the way into a repository, and a better slide628 <div className="relative min-h-0 grow overflow-hidden">
629 <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 glide630 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell631 <div className="mt-3 space-y-px">
632 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
633 Mission control
634 </SidebarLink>
635 <SidebarLink to="/explore" icon={<Compass size={15} />}>
636 Explore
637 </SidebarLink>
638 </div>
639
640 <SidebarGroup
Projects: what a workspace builds and runs, first on every page641 title="Projects"
Agents as a team: lifecycle, merge queue, billing and a new shell642 action={
643 ws && (
644 <Link
645 to={`/new?workspace=${ws.slug}`}
Projects: what a workspace builds and runs, first on every page646 aria-label="New project"
Agents as a team: lifecycle, merge queue, billing and a new shell647 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
648 >
649 <Plus size={13} />
650 </Link>
651 )
652 }
653 >
654 {listed.length === 0 && (
655 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
656 )}
657 {listed.map((repo) => {
658 const base = `/${repo.namespace}/${repo.name}`;
659 return (
660 <div key={base}>
661 <SidebarLink
662 to={base}
Projects: what a workspace builds and runs, first on every page663 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
Agents as a team: lifecycle, merge queue, billing and a new shell664 >
Projects: what a workspace builds and runs, first on every page665 <span className="text-[0.8125rem]">
Agents as a team: lifecycle, merge queue, billing and a new shell666 {repo.namespace !== ws?.slug && (
Projects: what a workspace builds and runs, first on every page667 <span className="font-mono text-faint">{repo.namespace}/</span>
Agents as a team: lifecycle, merge queue, billing and a new shell668 )}
Projects: what a workspace builds and runs, first on every page669 {"title" in repo && repo.title ? repo.title : repo.name}
Agents as a team: lifecycle, merge queue, billing and a new shell670 </span>
671 </SidebarLink>
672 </div>
673 );
674 })}
675 </SidebarGroup>
A flat project menu with Code first, views as tabs, and Stripe for every owner676 {ws && (
677 <SidebarGroup title="Across projects">
678 {roadmapIn("Workspace").map((item) => (
679 <SidebarSoonLink
680 key={item.key}
681 to={`/${ws.slug}/-/soon/${item.key}`}
682 icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />}
683 about={item.summary}
684 >
685 {item.title === "Board" ? "Boards" : item.title}
686 </SidebarSoonLink>
687 ))}
688 </SidebarGroup>
689 )}
Agents as a team: lifecycle, merge queue, billing and a new shell690 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide691 </div>
Sidebar: no settings flash on the way into a repository, and a better slide692 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
693 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
694 {side.current === "repo" && shown.current ? (
695 <RepoMenu
696 repo={shown.current}
697 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
698 open={inRepo}
699 />
700 ) : side.current === "account" || !ws ? (
701 <AccountSettingsMenu open={inAccount} />
702 ) : (
703 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
704 )}
705 </div>
706 </div>
A catalogue of model providers, and settings that feel like settings707 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell708
The project menu opens by section, and the workspace has its own panel709 {/* The workspace's own things sit at the bottom, by its usage and the
710 account: a panel of their own, ruled off from whatever menu is above,
711 however long it grows. */}
712 <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 control713 {ws && (
714 <div className="space-y-px">
The project menu opens by section, and the workspace has its own panel715 <p className="flex items-center gap-2 px-2 pb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-faint">
716 <span>Workspace</span>
717 <span className="min-w-0 truncate font-mono normal-case tracking-normal text-muted">{ws.slug}</span>
718 </p>
Workspace things at the bottom of the sidebar, and on mission control719 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
720 Usage
721 </SidebarLink>
722 {/* Everything else about the workspace lives in its settings, and only there. */}
723 <SidebarLink
724 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
725 icon={<Settings size={15} />}
726 >
727 Settings
728 </SidebarLink>
729 </div>
730 )}
A Vercel-like sidebar and a new mark731 {ws && <UsageCard slug={ws.slug} shell={shell} />}
732 <AccountMenu user={user} />
733 </div>
734 </div>
735 );
736}
737
738/** Words for the sections a path can end in. */
739const SECTIONS: Record<string, string> = {
740 issues: "Issues",
741 pulls: "Pull requests",
742 queue: "Merge queue",
743 commits: "Commits",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales744 plans: "Outcomes",
745 actions: "Workflows",
746 soon: "Soon",
Deployments: a preview for every pull request, production on g1t.page747 deployments: "Deployments",
Projects: what a workspace builds and runs, first on every page748 repository: "Repository",
Project dependencies: addresses, preview stacks, Affects, and agents who know749 dependencies: "Dependencies",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales750 code: "Files",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs751 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark752 settings: "Settings",
A catalogue of model providers, and settings that feel like settings753 people: "Members",
A Vercel-like sidebar and a new mark754 tokens: "Access tokens",
755 usage: "Usage",
Secrets and variables: one list, rows per environment, for workflows and deployments756 billing: "Billing and plans",
A catalogue of model providers, and settings that feel like settings757 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried758 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark759 tree: "Code",
760 blob: "Code",
761};
762
763/** Where the page is, as a trail of links: workspace / repository / section. */
764function Breadcrumbs({ pathname }: { pathname: string }) {
765 const parts = pathname.split("/").filter(Boolean);
766 const reserved = ["settings", "explore", "new", "search", "workspaces"];
767 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
768 if (reserved.includes(parts[0]!)) {
769 const words: Record<string, string> = {
Account settings in the same shape as a workspace's770 settings: "Account",
A Vercel-like sidebar and a new mark771 explore: "Explore",
Projects live: fixes from walking them end to end772 new: "New project",
A Vercel-like sidebar and a new mark773 search: "Search",
774 workspaces: "New workspace",
775 };
776 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
777 }
778 const [owner, second, third, fourth] = parts;
779 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
780 if (second === "-") {
A catalogue of model providers, and settings that feel like settings781 const page = `/${owner}/-/${third}`;
782 if (third && SETTINGS_PAGE.test(page)) {
783 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
784 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
785 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark786 } else if (second) {
787 const repo = `/${owner}/${second}`;
788 trail.push({ label: second, to: repo, mono: true });
789 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
790 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
791 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 deployments792 else if (third && SECTIONS[third]) {
793 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
794 // A settings page names which one: Settings / Secrets and variables.
795 if (third === "settings" && fourth && SECTIONS[fourth]) {
796 trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
797 }
798 }
A Vercel-like sidebar and a new mark799 }
800 return (
801 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
802 {trail.map((crumb, index) => (
803 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
804 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell805 <Link
A Vercel-like sidebar and a new mark806 to={crumb.to}
807 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
808 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
809 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell810 >
A Vercel-like sidebar and a new mark811 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell812 </Link>
A Vercel-like sidebar and a new mark813 </span>
814 ))}
815 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell816 );
817}
818
819type Command = { label: string; hint?: string; to: string; icon: ReactNode };
820
821/** Everything the palette can jump to, from what the sidebar already knows. */
822function commandsFor(user: User, shell: ShellData): Command[] {
823 const commands: Command[] = [
824 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
825 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
Projects: what a workspace builds and runs, first on every page826 { label: "New project", to: "/new", icon: <Plus size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell827 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
828 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
829 ];
830 const repo = shell.repo;
831 if (repo) {
832 const base = `/${repo.namespace}/${repo.name}`;
833 const name = `${repo.namespace}/${repo.name}`;
834 commands.unshift(
Projects: what a workspace builds and runs, first on every page835 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
836 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell837 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
838 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
839 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
840 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
841 ...(repo.member
842 ? [
843 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
Projects: what a workspace builds and runs, first on every page844 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
845 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
846 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
847 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell848 ]
849 : []),
850 );
851 }
852 for (const membership of user.workspaces ?? []) {
853 commands.push(
Workspace names and icons, and a component kit for every control854 { 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 request855 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
Projects: what a workspace builds and runs, first on every page856 { 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 settings857 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
858 { 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 shell859 );
860 }
861 for (const listed of shell.repos) {
862 commands.push({
Projects: what a workspace builds and runs, first on every page863 label: listed.title ?? listed.name,
864 hint: `${listed.namespace} · Project`,
Agents as a team: lifecycle, merge queue, billing and a new shell865 to: `/${listed.namespace}/${listed.name}`,
Projects: what a workspace builds and runs, first on every page866 icon: <Box size={15} />,
Agents as a team: lifecycle, merge queue, billing and a new shell867 });
868 }
869 return commands;
870}
871
872function CommandPalette({
873 open,
874 onClose,
875 commands,
876}: {
877 open: boolean;
878 onClose: () => void;
879 commands: Command[];
880}) {
881 const navigate = useNavigate();
882 const [query, setQuery] = useState("");
883 const [selected, setSelected] = useState(0);
884 const input = useRef<HTMLInputElement>(null);
885 const matches = useMemo(() => {
886 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
887 return commands
888 .filter((command) => {
889 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
890 return words.every((word) => text.includes(word));
891 })
892 .slice(0, 12);
893 }, [commands, query]);
894
895 useEffect(() => {
896 if (!open) return;
897 setQuery("");
898 setSelected(0);
899 input.current?.focus();
900 }, [open]);
901 useEffect(() => setSelected(0), [query]);
902
903 if (!open) return null;
904 const go = (command: Command | undefined) => {
905 if (!command) return;
906 onClose();
907 navigate(command.to);
908 };
909 return (
910 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
911 <div
912 role="dialog"
913 aria-label="Go to"
914 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
915 onClick={(event) => event.stopPropagation()}
916 >
917 <div className="flex items-center gap-3 border-b border-line px-4">
918 <Search size={16} className="text-faint" />
919 <input
920 ref={input}
921 value={query}
922 onChange={(event) => setQuery(event.target.value)}
923 onKeyDown={(event) => {
924 if (event.key === "ArrowDown") {
925 event.preventDefault();
926 setSelected((index) => Math.min(index + 1, matches.length - 1));
927 } else if (event.key === "ArrowUp") {
928 event.preventDefault();
929 setSelected((index) => Math.max(index - 1, 0));
930 } else if (event.key === "Enter") {
931 event.preventDefault();
932 go(matches[selected]);
933 } else if (event.key === "Escape") {
934 onClose();
935 }
936 }}
937 placeholder="Go to a repository, a page, or an action…"
938 autoComplete="off"
939 data-1p-ignore
940 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"
941 />
942 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
943 </div>
944 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
945 {matches.length === 0 && (
946 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
947 )}
948 {matches.map((command, index) => (
949 <li key={`${command.to}-${command.label}`}>
950 <button
951 type="button"
952 onMouseEnter={() => setSelected(index)}
953 onClick={() => go(command)}
954 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
955 index === selected ? "bg-line text-fg" : "text-muted"
956 }`}
957 >
958 <span className="shrink-0 text-faint">{command.icon}</span>
959 <span className="min-w-0 grow truncate">{command.label}</span>
960 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
961 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
962 </button>
963 </li>
964 ))}
965 </ul>
966 </div>
967 </div>
968 );
969}
970
971/**
972 * A bar across the top of the page while the next one loads, as GitHub
973 * has: it appears at once, creeps towards the end while waiting, then fills
974 * and fades when the page arrives.
975 */
976export function Progress() {
977 const navigation = useNavigation();
978 const busy = navigation.state !== "idle";
979 const [width, setWidth] = useState(0);
980 const [visible, setVisible] = useState(false);
981 useEffect(() => {
982 if (busy) {
983 setVisible(true);
984 setWidth(12);
985 // Slows as it goes, never quite arriving until the page does.
986 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
987 return () => clearInterval(timer);
988 }
989 setWidth((now) => (now > 0 ? 100 : 0));
990 const fade = setTimeout(() => setVisible(false), 250);
991 const reset = setTimeout(() => setWidth(0), 550);
992 return () => {
993 clearTimeout(fade);
994 clearTimeout(reset);
995 };
996 }, [busy]);
997 return (
998 <div
999 aria-hidden="true"
1000 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
1001 visible ? "opacity-100" : "opacity-0"
1002 }`}
1003 >
1004 <div
1005 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
1006 style={{ width: `${width}%` }}
1007 />
1008 </div>
1009 );
1010}
1011
1012/**
1013 * The signed-in app: a sidebar with the workspace, its repositories and the
1014 * sections of the one being looked at; a slim bar with search and the
1015 * account; and the page.
1016 */
1017export function AppShell({
1018 user,
1019 shell,
1020 banner,
1021 children,
1022}: {
1023 user: User;
1024 shell: ShellData;
1025 banner?: ReactNode;
1026 children: ReactNode;
1027}) {
1028 const submit = useSubmit();
1029 const { pathname } = useLocation();
1030 const [drawer, setDrawer] = useState(false);
1031 const [palette, setPalette] = useState(false);
1032 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
1033
1034 // A new page closes the drawer on small screens.
1035 useEffect(() => setDrawer(false), [pathname]);
1036 useEffect(() => {
1037 const onKey = (event: KeyboardEvent) => {
1038 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
1039 event.preventDefault();
1040 setPalette((open) => !open);
1041 }
1042 };
1043 window.addEventListener("keydown", onKey);
1044 return () => window.removeEventListener("keydown", onKey);
1045 }, []);
1046
1047 return (
1048 <div className="min-h-screen">
1049 <Progress />
1050 <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 mark1051 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1052 </aside>
1053 {drawer && (
1054 <div className="fixed inset-0 z-50 lg:hidden">
1055 <button
1056 type="button"
1057 aria-label="Close menu"
1058 className="absolute inset-0 bg-black/60"
1059 onClick={() => setDrawer(false)}
1060 />
1061 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
1062 <button
1063 type="button"
1064 aria-label="Close menu"
1065 onClick={() => setDrawer(false)}
1066 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
1067 >
1068 <X size={16} />
1069 </button>
A Vercel-like sidebar and a new mark1070 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1071 </aside>
1072 </div>
1073 )}
1074
Polish: phones, copy boxes, the plan page, the landing page, a real glide1075 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
Two limits, real invoices, trust that grows by itself, sales signals1076 <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 shell1077 <button
1078 type="button"
1079 aria-label="Open menu"
1080 onClick={() => setDrawer(true)}
1081 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
1082 >
1083 <Menu size={18} />
1084 </button>
A Vercel-like sidebar and a new mark1085 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell1086 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark1087 <a
1088 href="https://docs.g1t.sh/"
Two limits, real invoices, trust that grows by itself, sales signals1089 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 mark1090 >
1091 Docs
1092 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell1093 <DropdownMenu>
1094 <DropdownMenuTrigger
1095 aria-label="Create"
Two limits, real invoices, trust that grows by itself, sales signals1096 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 shell1097 >
A Vercel-like sidebar and a new mark1098 <Plus size={14} />
1099 New
Agents as a team: lifecycle, merge queue, billing and a new shell1100 </DropdownMenuTrigger>
1101 <DropdownMenuContent align="end">
1102 {shell.repo && (
1103 <DropdownMenuItem asChild>
1104 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
1105 <CircleDot />
1106 New issue
1107 </Link>
1108 </DropdownMenuItem>
1109 )}
1110 {shell.repo?.member && (
1111 <DropdownMenuItem asChild>
1112 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
1113 <ListTree />
1114 Plan work
1115 </Link>
1116 </DropdownMenuItem>
1117 )}
1118 <DropdownMenuItem asChild>
1119 <Link to="/new">
Projects: what a workspace builds and runs, first on every page1120 <Box />
1121 New project
Agents as a team: lifecycle, merge queue, billing and a new shell1122 </Link>
1123 </DropdownMenuItem>
1124 <DropdownMenuItem asChild>
1125 <Link to="/workspaces/new">
1126 <Users />
1127 New workspace
1128 </Link>
1129 </DropdownMenuItem>
1130 </DropdownMenuContent>
1131 </DropdownMenu>
1132 </div>
1133 </header>
1134 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide1135 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell1136 </div>
1137 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
1138 </div>
1139 );
1140}