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,124 lines43,984 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
44import type { User } from "@g1t/contracts";
45import { 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. */
62 workspace: { slug: string; role: "owner" | "member" } | 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
216function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
217 const current = shell.workspace?.slug;
218 return (
219 <DropdownMenu>
A Vercel-like sidebar and a new mark220 {/* The name goes to the workspace; only the arrows switch it. */}
221 <Link
222 to={current ? `/${current}` : "/workspaces/new"}
223 className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
224 >
Agents as a team: lifecycle, merge queue, billing and a new shell225 {current ? (
A Vercel-like sidebar and a new mark226 <Avatar name={current} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell227 ) : (
A Vercel-like sidebar and a new mark228 <span className="size-5 rounded-md border border-dashed border-line-strong" />
Agents as a team: lifecycle, merge queue, billing and a new shell229 )}
A Vercel-like sidebar and a new mark230 <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
231 {shell.workspace && (
232 <span className="shrink-0 rounded-full bg-raised px-1.5 py-px text-[0.625rem] font-medium text-muted ring-1 ring-line capitalize">
233 {shell.workspace.role}
Agents as a team: lifecycle, merge queue, billing and a new shell234 </span>
A Vercel-like sidebar and a new mark235 )}
236 </Link>
237 <DropdownMenuTrigger
238 aria-label="Switch workspace"
239 className="ml-auto flex h-8 w-6 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg"
240 >
241 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell242 </DropdownMenuTrigger>
243 <DropdownMenuContent align="start" className="w-60">
244 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
245 {(user.workspaces ?? []).map((membership) => (
246 <DropdownMenuItem asChild key={membership.slug}>
247 <Link to={`/${membership.slug}`}>
248 <Avatar name={membership.slug} size={16} square />
249 <span className="grow">{membership.slug}</span>
250 {membership.slug === current && <Check className="text-accent" />}
251 </Link>
252 </DropdownMenuItem>
253 ))}
254 <DropdownMenuSeparator />
255 <DropdownMenuItem asChild>
256 <Link to="/workspaces/new">
257 <Plus />
258 New workspace
259 </Link>
260 </DropdownMenuItem>
261 </DropdownMenuContent>
262 </DropdownMenu>
263 );
264}
265
Billing accounts, terms and enterprises; g1t is no longer free266/**
267 * This month's usage, and how close the workspace is to its usage limit,
268 * as Vercel shows a plan's usage.
269 */
A Vercel-like sidebar and a new mark270function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
Usage while free is shown at cost; agents get rustfmt and clippy271 if (shell.monthUsageMicros == null) return null;
272 const spent = shell.monthUsageMicros;
Billing accounts, terms and enterprises; g1t is no longer free273 const limit = shell.limit;
274 const ceiling = limit?.ceilingMicros ?? null;
275 const share = limit && ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
276 const tone = limit?.state === "stopped" ? "text-danger" : limit?.state === "warning" ? "text-warn" : "text-faint";
277 const bar = limit?.state === "stopped" ? "bg-danger" : limit?.state === "warning" ? "bg-warn" : "bg-accent";
A Vercel-like sidebar and a new mark278 return (
279 <Link
280 to={`/${slug}/-/usage`}
281 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
282 >
283 <span className="flex items-baseline justify-between text-xs">
284 <span className="font-medium text-fg">Usage</span>
285 <span className="text-faint">this month</span>
286 </span>
287 <span className="mt-2 flex items-baseline justify-between">
288 <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 forks289 {shell.free ? (
290 <span className="text-xs text-accent">Free for now</span>
Billing accounts, terms and enterprises; g1t is no longer free291 ) : limit?.comped ? (
292 <span className="text-xs text-accent">Comped</span>
Free while g1t is being built out; agents can check out their own forks293 ) : (
Billing accounts, terms and enterprises; g1t is no longer free294 ceiling != null && (
295 <span className={`text-xs ${tone}`}>
296 {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 forks297 </span>
298 )
A Vercel-like sidebar and a new mark299 )}
300 </span>
Billing accounts, terms and enterprises; g1t is no longer free301 {ceiling != null && !limit?.comped && (
302 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
303 <span
304 className={`block h-full rounded-full ${bar}`}
305 style={{ width: `${Math.max(share * 100, share > 0 ? 3 : 0)}%` }}
306 />
307 </span>
308 )}
A Vercel-like sidebar and a new mark309 </Link>
310 );
311}
312
313function AccountMenu({ user }: { user: User }) {
314 const submit = useSubmit();
315 return (
316 <DropdownMenu>
317 <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">
318 <Avatar name={user.username} size={22} />
319 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
320 <Ellipsis size={15} className="shrink-0 text-faint" />
321 </DropdownMenuTrigger>
322 <DropdownMenuContent align="start" side="top" className="w-56">
323 <DropdownMenuLabel>
324 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
325 </DropdownMenuLabel>
326 <DropdownMenuSeparator />
327 <DropdownMenuItem asChild>
328 <Link to="/settings">
329 <Settings />
330 Your settings
331 </Link>
332 </DropdownMenuItem>
333 <DropdownMenuItem asChild>
334 <a href="https://docs.g1t.sh/">
335 <BookOpen />
336 Documentation
337 </a>
338 </DropdownMenuItem>
339 <DropdownMenuSeparator />
340 {/* Submitted from here: the menu closes on select, and a button
341 that has left the page cannot submit a form. */}
342 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
343 <LogOut />
344 Sign out
345 </DropdownMenuItem>
346 </DropdownMenuContent>
347 </DropdownMenu>
348 );
349}
350
A catalogue of model providers, and settings that feel like settings351/** A workspace's settings pages, which the sidebar slides over to. */
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs352const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
A catalogue of model providers, and settings that feel like settings353
Polish: phones, copy boxes, the plan page, the landing page, a real glide354/**
Sidebar: no settings flash on the way into a repository, and a better slide355 * The sidebar's menus are two layers, the way a phone pushes a screen: the
356 * one arriving slides in from the right over the full width, while the one
357 * leaving drifts a quarter of the way left and fades, both on one long
Sidebar: the panels really slide358 * ease-out, the fade quicker than the move so the two never blur together.
359 * Going back reverses it.
Polish: phones, copy boxes, the plan page, the landing page, a real glide360 */
Sidebar: no settings flash on the way into a repository, and a better slide361const LAYER =
Sidebar: the panels really slide362 "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 slide363/** One menu, filling its layer. */
364const PANEL = "h-full overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings365
366/** A workspace's settings, as the sidebar shows them in place of everything else. */
367function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
368 return (
369 <nav
370 aria-label="Workspace settings"
371 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide372 className={PANEL}
A catalogue of model providers, and settings that feel like settings373 >
374 <Link
375 to={`/${slug}`}
376 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"
377 >
378 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
379 <span className="truncate">{slug}</span>
380 </Link>
381 <SidebarGroup title="Settings">
382 {owner && (
383 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
384 General
385 </SidebarLink>
386 )}
387 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
388 Members
389 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments390 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
391 Access tokens
392 </SidebarLink>
A catalogue of model providers, and settings that feel like settings393 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
Secrets and variables: one list, rows per environment, for workflows and deployments394 Billing and plans
A catalogue of model providers, and settings that feel like settings395 </SidebarLink>
396 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
397 Integrations
Webhooks: every event, to your own addresses, signed and retried398 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs399 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
400 Secrets and variables
401 </SidebarLink>
Secrets and variables: one list, rows per environment, for workflows and deployments402 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
403 Webhooks
A catalogue of model providers, and settings that feel like settings404 </SidebarLink>
405 </SidebarGroup>
406 </nav>
407 );
408}
409
Sidebar: no settings flash on the way into a repository, and a better slide410/**
411 * What the repository menu needs. Known from the address before the page's
412 * data arrives, so the menu is right from the first frame; the counts fill
413 * in once it does.
414 */
415type MenuRepo = {
416 namespace: string;
417 name: string;
418 member: boolean;
419 issues?: number;
420 pulls?: number;
421};
422
423function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
424 return (
425 a != null &&
426 b != null &&
427 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
428 a.name.toLowerCase() === b.name.toLowerCase()
429 );
430}
A repository has its own sidebar, as settings do431
432/**
Projects: what a workspace builds and runs, first on every page433 * A project's own menu, which the sidebar slides to while you are in it,
434 * as it does for settings: everything about the project, running and its
435 * code, and nothing else, with the way back to everything.
A repository has its own sidebar, as settings do436 */
Sidebar: no settings flash on the way into a repository, and a better slide437function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
A repository has its own sidebar, as settings do438 const base = `/${repo.namespace}/${repo.name}`;
439 return (
440 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
441 <Link
442 to="/"
443 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"
444 >
445 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
446 Mission control
447 </Link>
A flat project menu with Code first, views as tabs, and Stripe for every owner448 <NavLink
A repository has its own sidebar, as settings do449 to={base}
A flat project menu with Code first, views as tabs, and Stripe for every owner450 end
451 title="Overview"
452 className={({ isActive }) =>
453 `mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
454 }
A repository has its own sidebar, as settings do455 >
456 <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 page457 {isPrivate ? <Lock size={13} /> : <Box size={13} />}
A repository has its own sidebar, as settings do458 </span>
459 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
460 <span className="text-faint">{repo.namespace}/</span>
461 <span className="font-semibold text-fg">{repo.name}</span>
462 </span>
A flat project menu with Code first, views as tabs, and Stripe for every owner463 </NavLink>
464 {/* A project's pages, flat and in the order people use them. A page
465 with more than one view shows them as tabs across its top. */}
Projects: what a workspace builds and runs, first on every page466 <div className="mt-3 space-y-px">
A flat project menu with Code first, views as tabs, and Stripe for every owner467 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
468 Code
469 </SidebarLink>
470 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
471 Issues
472 </SidebarLink>
473 <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
474 Pull requests
475 </SidebarLink>
476 <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.">
477 Agents
478 </SidebarSoonLink>
479 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
480 Workflows
Projects: what a workspace builds and runs, first on every page481 </SidebarLink>
A flat project menu with Code first, views as tabs, and Stripe for every owner482 {repo.member ? (
483 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
484 Deployments
485 </SidebarLink>
486 ) : null}
487 <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.">
488 Security
489 </SidebarSoonLink>
490 <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do.">
491 Insights
492 </SidebarSoonLink>
Projects: what a workspace builds and runs, first on every page493 {repo.member && (
A flat project menu with Code first, views as tabs, and Stripe for every owner494 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
495 Settings
496 </SidebarLink>
Projects: what a workspace builds and runs, first on every page497 )}
498 </div>
A repository has its own sidebar, as settings do499 </nav>
500 );
501}
502
Account settings in the same shape as a workspace's503/** Your own settings, as the sidebar shows them on the settings page. */
504function AccountSettingsMenu({ open }: { open: boolean }) {
505 const { hash } = useLocation();
506 const item = (id: string, icon: ReactNode, label: string) => (
507 <Link
508 to={`/settings#${id}`}
509 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
510 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
511 }`}
512 >
513 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
514 {label}
515 </Link>
516 );
517 return (
518 <nav
519 aria-label="Your settings"
520 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide521 className={PANEL}
Account settings in the same shape as a workspace's522 >
523 <Link
524 to="/"
525 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"
526 >
527 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
528 Mission control
529 </Link>
530 <SidebarGroup title="Account">
Secrets and variables: one list, rows per environment, for workflows and deployments531 {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")}
532 {item("tokens", <KeyRound size={15} />, "Access tokens")}
Account settings in the same shape as a workspace's533 {item("applications", <Plug size={15} />, "Connected applications")}
534 </SidebarGroup>
535 </nav>
536 );
537}
538
A Vercel-like sidebar and a new mark539function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell540 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings541 const { pathname } = useLocation();
542 const going = useNavigation().location?.pathname;
543 // On a workspace's settings page, or on the way to one, its settings take
544 // the sidebar over.
545 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's546 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do547 const active = shell.repo;
548 // In a repository, or on the way into one, its own menu takes the sidebar.
549 const target = going ?? pathname;
550 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
551 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
552 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
553 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide554 // The repository the menu is for: the one loaded if it is the one being
555 // gone to, else what the address says, at once.
556 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
557 const menuRepo: MenuRepo | null = targetRepo
558 ? sameRepo(active, targetRepo)
559 ? active
560 : {
561 ...targetRepo,
562 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
563 }
564 : null;
A repository has its own sidebar, as settings do565 // What sits on the far side of the track. Kept while sliding back, so it
566 // does not vanish on the way out.
567 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide568 if (inAccount) side.current = "account";
569 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do570 else if (inRepo) side.current = "repo";
571 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide572 const shown = useRef<MenuRepo | null>(menuRepo);
573 if (menuRepo) shown.current = menuRepo;
574 // Moving between two of the far-side menus (settings to a repository,
575 // one repository to another) crossfades in place instead of sliding.
576 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
577 const lastAway = useRef(away);
578 const lastKey = useRef(detailKey);
579 const swapped = lastAway.current && away && lastKey.current !== detailKey;
580 useEffect(() => {
581 lastAway.current = away;
582 lastKey.current = detailKey;
583 });
Agents as a team: lifecycle, merge queue, billing and a new shell584 // The repository being looked at is listed even when it is someone else's.
585 const listed =
586 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
587 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
588 : shell.repos;
589 return (
590 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark591 {/* The same height and rule as the top bar, so the two read as one line. */}
Two limits, real invoices, trust that grows by itself, sales signals592 <div className="flex h-16 shrink-0 items-center gap-2 border-b border-line px-3">
593 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-2 hover:bg-raised">
594 <Mark className="size-6" />
Agents as a team: lifecycle, merge queue, billing and a new shell595 </Link>
A Vercel-like sidebar and a new mark596 <span className="shrink-0 text-line-strong" aria-hidden="true">
597 /
598 </span>
599 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell600 </div>
A Vercel-like sidebar and a new mark601 <div className="px-2 pt-3">
602 <button
603 type="button"
604 onClick={onFind}
Two limits, real invoices, trust that grows by itself, sales signals605 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 mark606 >
607 <Search size={14} />
608 <span className="grow text-left">Find…</span>
609 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
610 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell611 </div>
Sidebar: no settings flash on the way into a repository, and a better slide612 <div className="relative min-h-0 grow overflow-hidden">
613 <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 glide614 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell615 <div className="mt-3 space-y-px">
616 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
617 Mission control
618 </SidebarLink>
619 <SidebarLink to="/explore" icon={<Compass size={15} />}>
620 Explore
621 </SidebarLink>
622 </div>
623
624 <SidebarGroup
Projects: what a workspace builds and runs, first on every page625 title="Projects"
Agents as a team: lifecycle, merge queue, billing and a new shell626 action={
627 ws && (
628 <Link
629 to={`/new?workspace=${ws.slug}`}
Projects: what a workspace builds and runs, first on every page630 aria-label="New project"
Agents as a team: lifecycle, merge queue, billing and a new shell631 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
632 >
633 <Plus size={13} />
634 </Link>
635 )
636 }
637 >
638 {listed.length === 0 && (
639 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
640 )}
641 {listed.map((repo) => {
642 const base = `/${repo.namespace}/${repo.name}`;
643 return (
644 <div key={base}>
645 <SidebarLink
646 to={base}
Projects: what a workspace builds and runs, first on every page647 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
Agents as a team: lifecycle, merge queue, billing and a new shell648 >
Projects: what a workspace builds and runs, first on every page649 <span className="text-[0.8125rem]">
Agents as a team: lifecycle, merge queue, billing and a new shell650 {repo.namespace !== ws?.slug && (
Projects: what a workspace builds and runs, first on every page651 <span className="font-mono text-faint">{repo.namespace}/</span>
Agents as a team: lifecycle, merge queue, billing and a new shell652 )}
Projects: what a workspace builds and runs, first on every page653 {"title" in repo && repo.title ? repo.title : repo.name}
Agents as a team: lifecycle, merge queue, billing and a new shell654 </span>
655 </SidebarLink>
656 </div>
657 );
658 })}
659 </SidebarGroup>
A flat project menu with Code first, views as tabs, and Stripe for every owner660 {ws && (
661 <SidebarGroup title="Across projects">
662 {roadmapIn("Workspace").map((item) => (
663 <SidebarSoonLink
664 key={item.key}
665 to={`/${ws.slug}/-/soon/${item.key}`}
666 icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />}
667 about={item.summary}
668 >
669 {item.title === "Board" ? "Boards" : item.title}
670 </SidebarSoonLink>
671 ))}
672 </SidebarGroup>
673 )}
Agents as a team: lifecycle, merge queue, billing and a new shell674 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide675 </div>
Sidebar: no settings flash on the way into a repository, and a better slide676 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
677 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
678 {side.current === "repo" && shown.current ? (
679 <RepoMenu
680 repo={shown.current}
681 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
682 open={inRepo}
683 />
684 ) : side.current === "account" || !ws ? (
685 <AccountSettingsMenu open={inAccount} />
686 ) : (
687 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
688 )}
689 </div>
690 </div>
A catalogue of model providers, and settings that feel like settings691 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell692
The project menu opens by section, and the workspace has its own panel693 {/* The workspace's own things sit at the bottom, by its usage and the
694 account: a panel of their own, ruled off from whatever menu is above,
695 however long it grows. */}
696 <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 control697 {ws && (
698 <div className="space-y-px">
The project menu opens by section, and the workspace has its own panel699 <p className="flex items-center gap-2 px-2 pb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-faint">
700 <span>Workspace</span>
701 <span className="min-w-0 truncate font-mono normal-case tracking-normal text-muted">{ws.slug}</span>
702 </p>
Workspace things at the bottom of the sidebar, and on mission control703 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
704 Usage
705 </SidebarLink>
706 {/* Everything else about the workspace lives in its settings, and only there. */}
707 <SidebarLink
708 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
709 icon={<Settings size={15} />}
710 >
711 Settings
712 </SidebarLink>
713 </div>
714 )}
A Vercel-like sidebar and a new mark715 {ws && <UsageCard slug={ws.slug} shell={shell} />}
716 <AccountMenu user={user} />
717 </div>
718 </div>
719 );
720}
721
722/** Words for the sections a path can end in. */
723const SECTIONS: Record<string, string> = {
724 issues: "Issues",
725 pulls: "Pull requests",
726 queue: "Merge queue",
727 commits: "Commits",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales728 plans: "Outcomes",
729 actions: "Workflows",
730 soon: "Soon",
Deployments: a preview for every pull request, production on g1t.page731 deployments: "Deployments",
Projects: what a workspace builds and runs, first on every page732 repository: "Repository",
Project dependencies: addresses, preview stacks, Affects, and agents who know733 dependencies: "Dependencies",
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales734 code: "Files",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs735 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark736 settings: "Settings",
A catalogue of model providers, and settings that feel like settings737 people: "Members",
A Vercel-like sidebar and a new mark738 tokens: "Access tokens",
739 usage: "Usage",
Secrets and variables: one list, rows per environment, for workflows and deployments740 billing: "Billing and plans",
A catalogue of model providers, and settings that feel like settings741 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried742 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark743 tree: "Code",
744 blob: "Code",
745};
746
747/** Where the page is, as a trail of links: workspace / repository / section. */
748function Breadcrumbs({ pathname }: { pathname: string }) {
749 const parts = pathname.split("/").filter(Boolean);
750 const reserved = ["settings", "explore", "new", "search", "workspaces"];
751 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
752 if (reserved.includes(parts[0]!)) {
753 const words: Record<string, string> = {
Account settings in the same shape as a workspace's754 settings: "Account",
A Vercel-like sidebar and a new mark755 explore: "Explore",
Projects live: fixes from walking them end to end756 new: "New project",
A Vercel-like sidebar and a new mark757 search: "Search",
758 workspaces: "New workspace",
759 };
760 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
761 }
762 const [owner, second, third, fourth] = parts;
763 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
764 if (second === "-") {
A catalogue of model providers, and settings that feel like settings765 const page = `/${owner}/-/${third}`;
766 if (third && SETTINGS_PAGE.test(page)) {
767 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
768 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
769 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark770 } else if (second) {
771 const repo = `/${owner}/${second}`;
772 trail.push({ label: second, to: repo, mono: true });
773 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
774 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
775 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 deployments776 else if (third && SECTIONS[third]) {
777 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
778 // A settings page names which one: Settings / Secrets and variables.
779 if (third === "settings" && fourth && SECTIONS[fourth]) {
780 trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
781 }
782 }
A Vercel-like sidebar and a new mark783 }
784 return (
785 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
786 {trail.map((crumb, index) => (
787 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
788 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell789 <Link
A Vercel-like sidebar and a new mark790 to={crumb.to}
791 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
792 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
793 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell794 >
A Vercel-like sidebar and a new mark795 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell796 </Link>
A Vercel-like sidebar and a new mark797 </span>
798 ))}
799 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell800 );
801}
802
803type Command = { label: string; hint?: string; to: string; icon: ReactNode };
804
805/** Everything the palette can jump to, from what the sidebar already knows. */
806function commandsFor(user: User, shell: ShellData): Command[] {
807 const commands: Command[] = [
808 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
809 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
Projects: what a workspace builds and runs, first on every page810 { label: "New project", to: "/new", icon: <Plus size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell811 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
812 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
813 ];
814 const repo = shell.repo;
815 if (repo) {
816 const base = `/${repo.namespace}/${repo.name}`;
817 const name = `${repo.namespace}/${repo.name}`;
818 commands.unshift(
Projects: what a workspace builds and runs, first on every page819 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
820 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell821 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
822 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
823 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
824 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
825 ...(repo.member
826 ? [
827 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
Projects: what a workspace builds and runs, first on every page828 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
829 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
830 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
831 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
Agents as a team: lifecycle, merge queue, billing and a new shell832 ]
833 : []),
834 );
835 }
836 for (const membership of user.workspaces ?? []) {
837 commands.push(
838 { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request839 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
Projects: what a workspace builds and runs, first on every page840 { 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 settings841 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
842 { 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 shell843 );
844 }
845 for (const listed of shell.repos) {
846 commands.push({
Projects: what a workspace builds and runs, first on every page847 label: listed.title ?? listed.name,
848 hint: `${listed.namespace} · Project`,
Agents as a team: lifecycle, merge queue, billing and a new shell849 to: `/${listed.namespace}/${listed.name}`,
Projects: what a workspace builds and runs, first on every page850 icon: <Box size={15} />,
Agents as a team: lifecycle, merge queue, billing and a new shell851 });
852 }
853 return commands;
854}
855
856function CommandPalette({
857 open,
858 onClose,
859 commands,
860}: {
861 open: boolean;
862 onClose: () => void;
863 commands: Command[];
864}) {
865 const navigate = useNavigate();
866 const [query, setQuery] = useState("");
867 const [selected, setSelected] = useState(0);
868 const input = useRef<HTMLInputElement>(null);
869 const matches = useMemo(() => {
870 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
871 return commands
872 .filter((command) => {
873 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
874 return words.every((word) => text.includes(word));
875 })
876 .slice(0, 12);
877 }, [commands, query]);
878
879 useEffect(() => {
880 if (!open) return;
881 setQuery("");
882 setSelected(0);
883 input.current?.focus();
884 }, [open]);
885 useEffect(() => setSelected(0), [query]);
886
887 if (!open) return null;
888 const go = (command: Command | undefined) => {
889 if (!command) return;
890 onClose();
891 navigate(command.to);
892 };
893 return (
894 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
895 <div
896 role="dialog"
897 aria-label="Go to"
898 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
899 onClick={(event) => event.stopPropagation()}
900 >
901 <div className="flex items-center gap-3 border-b border-line px-4">
902 <Search size={16} className="text-faint" />
903 <input
904 ref={input}
905 value={query}
906 onChange={(event) => setQuery(event.target.value)}
907 onKeyDown={(event) => {
908 if (event.key === "ArrowDown") {
909 event.preventDefault();
910 setSelected((index) => Math.min(index + 1, matches.length - 1));
911 } else if (event.key === "ArrowUp") {
912 event.preventDefault();
913 setSelected((index) => Math.max(index - 1, 0));
914 } else if (event.key === "Enter") {
915 event.preventDefault();
916 go(matches[selected]);
917 } else if (event.key === "Escape") {
918 onClose();
919 }
920 }}
921 placeholder="Go to a repository, a page, or an action…"
922 autoComplete="off"
923 data-1p-ignore
924 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"
925 />
926 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
927 </div>
928 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
929 {matches.length === 0 && (
930 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
931 )}
932 {matches.map((command, index) => (
933 <li key={`${command.to}-${command.label}`}>
934 <button
935 type="button"
936 onMouseEnter={() => setSelected(index)}
937 onClick={() => go(command)}
938 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
939 index === selected ? "bg-line text-fg" : "text-muted"
940 }`}
941 >
942 <span className="shrink-0 text-faint">{command.icon}</span>
943 <span className="min-w-0 grow truncate">{command.label}</span>
944 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
945 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
946 </button>
947 </li>
948 ))}
949 </ul>
950 </div>
951 </div>
952 );
953}
954
955/**
956 * A bar across the top of the page while the next one loads, as GitHub
957 * has: it appears at once, creeps towards the end while waiting, then fills
958 * and fades when the page arrives.
959 */
960export function Progress() {
961 const navigation = useNavigation();
962 const busy = navigation.state !== "idle";
963 const [width, setWidth] = useState(0);
964 const [visible, setVisible] = useState(false);
965 useEffect(() => {
966 if (busy) {
967 setVisible(true);
968 setWidth(12);
969 // Slows as it goes, never quite arriving until the page does.
970 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
971 return () => clearInterval(timer);
972 }
973 setWidth((now) => (now > 0 ? 100 : 0));
974 const fade = setTimeout(() => setVisible(false), 250);
975 const reset = setTimeout(() => setWidth(0), 550);
976 return () => {
977 clearTimeout(fade);
978 clearTimeout(reset);
979 };
980 }, [busy]);
981 return (
982 <div
983 aria-hidden="true"
984 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
985 visible ? "opacity-100" : "opacity-0"
986 }`}
987 >
988 <div
989 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
990 style={{ width: `${width}%` }}
991 />
992 </div>
993 );
994}
995
996/**
997 * The signed-in app: a sidebar with the workspace, its repositories and the
998 * sections of the one being looked at; a slim bar with search and the
999 * account; and the page.
1000 */
1001export function AppShell({
1002 user,
1003 shell,
1004 banner,
1005 children,
1006}: {
1007 user: User;
1008 shell: ShellData;
1009 banner?: ReactNode;
1010 children: ReactNode;
1011}) {
1012 const submit = useSubmit();
1013 const { pathname } = useLocation();
1014 const [drawer, setDrawer] = useState(false);
1015 const [palette, setPalette] = useState(false);
1016 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
1017
1018 // A new page closes the drawer on small screens.
1019 useEffect(() => setDrawer(false), [pathname]);
1020 useEffect(() => {
1021 const onKey = (event: KeyboardEvent) => {
1022 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
1023 event.preventDefault();
1024 setPalette((open) => !open);
1025 }
1026 };
1027 window.addEventListener("keydown", onKey);
1028 return () => window.removeEventListener("keydown", onKey);
1029 }, []);
1030
1031 return (
1032 <div className="min-h-screen">
1033 <Progress />
1034 <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 mark1035 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1036 </aside>
1037 {drawer && (
1038 <div className="fixed inset-0 z-50 lg:hidden">
1039 <button
1040 type="button"
1041 aria-label="Close menu"
1042 className="absolute inset-0 bg-black/60"
1043 onClick={() => setDrawer(false)}
1044 />
1045 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
1046 <button
1047 type="button"
1048 aria-label="Close menu"
1049 onClick={() => setDrawer(false)}
1050 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
1051 >
1052 <X size={16} />
1053 </button>
A Vercel-like sidebar and a new mark1054 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell1055 </aside>
1056 </div>
1057 )}
1058
Polish: phones, copy boxes, the plan page, the landing page, a real glide1059 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
Two limits, real invoices, trust that grows by itself, sales signals1060 <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 shell1061 <button
1062 type="button"
1063 aria-label="Open menu"
1064 onClick={() => setDrawer(true)}
1065 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
1066 >
1067 <Menu size={18} />
1068 </button>
A Vercel-like sidebar and a new mark1069 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell1070 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark1071 <a
1072 href="https://docs.g1t.sh/"
Two limits, real invoices, trust that grows by itself, sales signals1073 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 mark1074 >
1075 Docs
1076 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell1077 <DropdownMenu>
1078 <DropdownMenuTrigger
1079 aria-label="Create"
Two limits, real invoices, trust that grows by itself, sales signals1080 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 shell1081 >
A Vercel-like sidebar and a new mark1082 <Plus size={14} />
1083 New
Agents as a team: lifecycle, merge queue, billing and a new shell1084 </DropdownMenuTrigger>
1085 <DropdownMenuContent align="end">
1086 {shell.repo && (
1087 <DropdownMenuItem asChild>
1088 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
1089 <CircleDot />
1090 New issue
1091 </Link>
1092 </DropdownMenuItem>
1093 )}
1094 {shell.repo?.member && (
1095 <DropdownMenuItem asChild>
1096 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
1097 <ListTree />
1098 Plan work
1099 </Link>
1100 </DropdownMenuItem>
1101 )}
1102 <DropdownMenuItem asChild>
1103 <Link to="/new">
Projects: what a workspace builds and runs, first on every page1104 <Box />
1105 New project
Agents as a team: lifecycle, merge queue, billing and a new shell1106 </Link>
1107 </DropdownMenuItem>
1108 <DropdownMenuItem asChild>
1109 <Link to="/workspaces/new">
1110 <Users />
1111 New workspace
1112 </Link>
1113 </DropdownMenuItem>
1114 </DropdownMenuContent>
1115 </DropdownMenu>
1116 </div>
1117 </header>
1118 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide1119 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell1120 </div>
1121 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
1122 </div>
1123 );
1124}