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

995 lines38,488 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,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request3 BarChart3,
Agents as a team: lifecycle, merge queue, billing and a new shell4 BookMarked,
5 BookOpen,
6 Check,
7 ChevronsUpDown,
A Vercel-like sidebar and a new mark8 Ellipsis,
Agents as a team: lifecycle, merge queue, billing and a new shell9 CircleDot,
10 Code2,
11 Compass,
12 CornerDownLeft,
13 CreditCard,
14 GitPullRequest,
15 History,
16 KeyRound,
17 Layers,
18 LayoutDashboard,
19 ListTree,
20 Lock,
21 LogOut,
22 Menu,
23 Plus,
24 Search,
Integrations: your own model provider, alerts that open issues, tickets agents read25 Plug,
Agents as a team: lifecycle, merge queue, billing and a new shell26 Settings,
27 Users,
Webhooks: every event, to your own addresses, signed and retried28 Webhook,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs29 PlayCircle,
Deployments: a preview for every pull request, production on g1t.page30 Rocket,
Agents as a team: lifecycle, merge queue, billing and a new shell31 X,
32} from "lucide-react";
33import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
34import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
35
36import type { User } from "@g1t/contracts";
37import { MICROS_PER_DOLLAR } from "@g1t/contracts";
38
A Vercel-like sidebar and a new mark39import { Mark } from "./logo";
Agents as a team: lifecycle, merge queue, billing and a new shell40import { Avatar } from "./ui";
41import {
42 DropdownMenu,
43 DropdownMenuContent,
44 DropdownMenuItem,
45 DropdownMenuLabel,
46 DropdownMenuSeparator,
47 DropdownMenuTrigger,
48} from "./ui/dropdown-menu";
49
50/** What the sidebar needs, worked out by the root loader for a signed-in person. */
51export type ShellData = {
52 /** The workspace the sidebar is about: the one being looked at, or their first. */
53 workspace: { slug: string; role: "owner" | "member" } | null;
54 /** Its repositories, newest first. */
55 repos: { namespace: string; name: string; isPrivate: boolean }[];
56 /** The repository being looked at, if any, whoever owns it. */
57 repo: {
58 namespace: string;
59 name: string;
60 member: boolean;
61 issues: number;
62 pulls: number;
63 } | null;
64 /** The workspace's agent credit, if billing is on and they may see it. */
65 creditMicros: number | null;
Free while g1t is being built out; agents can check out their own forks66 /** Whether g1t charges nothing for now, while it is being built out. */
67 free?: boolean;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request68 /** What its agents have cost since the start of the month. */
Usage while free is shown at cost; agents get rustfmt and clippy69 /** This month's usage: charged, or at cost while g1t is free. */
70 monthUsageMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell71};
72
73function SidebarLink({
74 to,
75 icon,
76 end,
77 count,
78 also,
79 children,
80}: {
81 to: string;
82 icon: ReactNode;
83 end?: boolean;
84 count?: number;
85 /** Another path prefix under which this link is the current one. */
86 also?: string;
87 children: ReactNode;
88}) {
89 const { pathname } = useLocation();
90 return (
91 <NavLink
92 to={to}
93 end={end}
94 prefetch="intent"
95 className={({ isActive, isPending }) => {
96 const current = isActive || (also != null && pathname.startsWith(also + "/"));
A Vercel-like sidebar and a new mark97 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 shell98 current
99 ? "bg-raised font-medium text-fg"
100 : isPending
101 ? "bg-raised/60 text-fg"
102 : "text-muted hover:bg-raised/60 hover:text-fg"
103 }`;
104 }}
105 >
106 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
107 <span className="min-w-0 grow truncate">{children}</span>
108 {count != null && count > 0 && (
109 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
110 {count}
111 </span>
112 )}
113 </NavLink>
114 );
115}
116
117function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
118 return (
A Vercel-like sidebar and a new mark119 <section className="mt-6">
120 <div className="mb-1 flex h-6 items-center justify-between px-2">
121 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell122 {action}
123 </div>
124 <div className="space-y-px">{children}</div>
125 </section>
126 );
127}
128
129function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
130 const current = shell.workspace?.slug;
131 return (
132 <DropdownMenu>
A Vercel-like sidebar and a new mark133 {/* The name goes to the workspace; only the arrows switch it. */}
134 <Link
135 to={current ? `/${current}` : "/workspaces/new"}
136 className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
137 >
Agents as a team: lifecycle, merge queue, billing and a new shell138 {current ? (
A Vercel-like sidebar and a new mark139 <Avatar name={current} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell140 ) : (
A Vercel-like sidebar and a new mark141 <span className="size-5 rounded-md border border-dashed border-line-strong" />
Agents as a team: lifecycle, merge queue, billing and a new shell142 )}
A Vercel-like sidebar and a new mark143 <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
144 {shell.workspace && (
145 <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">
146 {shell.workspace.role}
Agents as a team: lifecycle, merge queue, billing and a new shell147 </span>
A Vercel-like sidebar and a new mark148 )}
149 </Link>
150 <DropdownMenuTrigger
151 aria-label="Switch workspace"
152 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"
153 >
154 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell155 </DropdownMenuTrigger>
156 <DropdownMenuContent align="start" className="w-60">
157 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
158 {(user.workspaces ?? []).map((membership) => (
159 <DropdownMenuItem asChild key={membership.slug}>
160 <Link to={`/${membership.slug}`}>
161 <Avatar name={membership.slug} size={16} square />
162 <span className="grow">{membership.slug}</span>
163 {membership.slug === current && <Check className="text-accent" />}
164 </Link>
165 </DropdownMenuItem>
166 ))}
167 <DropdownMenuSeparator />
168 <DropdownMenuItem asChild>
169 <Link to="/workspaces/new">
170 <Plus />
171 New workspace
172 </Link>
173 </DropdownMenuItem>
174 </DropdownMenuContent>
175 </DropdownMenu>
176 );
177}
178
A Vercel-like sidebar and a new mark179/** This month's spend against what is left, as Vercel shows a plan's usage. */
180function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
Usage while free is shown at cost; agents get rustfmt and clippy181 if (shell.monthUsageMicros == null) return null;
182 const spent = shell.monthUsageMicros;
A Vercel-like sidebar and a new mark183 const left = shell.creditMicros;
184 const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0;
185 return (
186 <Link
187 to={`/${slug}/-/usage`}
188 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
189 >
190 <span className="flex items-baseline justify-between text-xs">
191 <span className="font-medium text-fg">Usage</span>
192 <span className="text-faint">this month</span>
193 </span>
194 <span className="mt-2 flex items-baseline justify-between">
195 <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 forks196 {shell.free ? (
197 <span className="text-xs text-accent">Free for now</span>
198 ) : (
199 left != null && (
200 <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}>
201 ${(left / MICROS_PER_DOLLAR).toFixed(2)} left
202 </span>
203 )
A Vercel-like sidebar and a new mark204 )}
205 </span>
206 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
207 <span
208 className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`}
209 style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }}
210 />
211 </span>
212 </Link>
213 );
214}
215
216function AccountMenu({ user }: { user: User }) {
217 const submit = useSubmit();
218 return (
219 <DropdownMenu>
220 <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">
221 <Avatar name={user.username} size={22} />
222 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
223 <Ellipsis size={15} className="shrink-0 text-faint" />
224 </DropdownMenuTrigger>
225 <DropdownMenuContent align="start" side="top" className="w-56">
226 <DropdownMenuLabel>
227 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
228 </DropdownMenuLabel>
229 <DropdownMenuSeparator />
230 <DropdownMenuItem asChild>
231 <Link to="/settings">
232 <Settings />
233 Your settings
234 </Link>
235 </DropdownMenuItem>
236 <DropdownMenuItem asChild>
237 <a href="https://docs.g1t.sh/">
238 <BookOpen />
239 Documentation
240 </a>
241 </DropdownMenuItem>
242 <DropdownMenuSeparator />
243 {/* Submitted from here: the menu closes on select, and a button
244 that has left the page cannot submit a form. */}
245 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
246 <LogOut />
247 Sign out
248 </DropdownMenuItem>
249 </DropdownMenuContent>
250 </DropdownMenu>
251 );
252}
253
A catalogue of model providers, and settings that feel like settings254/** A workspace's settings pages, which the sidebar slides over to. */
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs255const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
A catalogue of model providers, and settings that feel like settings256
Polish: phones, copy boxes, the plan page, the landing page, a real glide257/**
Sidebar: no settings flash on the way into a repository, and a better slide258 * The sidebar's menus are two layers, the way a phone pushes a screen: the
259 * one arriving slides in from the right over the full width, while the one
260 * leaving drifts a quarter of the way left and fades, both on one long
Sidebar: the panels really slide261 * ease-out, the fade quicker than the move so the two never blur together.
262 * Going back reverses it.
Polish: phones, copy boxes, the plan page, the landing page, a real glide263 */
Sidebar: no settings flash on the way into a repository, and a better slide264const LAYER =
Sidebar: the panels really slide265 "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 slide266/** One menu, filling its layer. */
267const PANEL = "h-full overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings268
269/** A workspace's settings, as the sidebar shows them in place of everything else. */
270function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
271 return (
272 <nav
273 aria-label="Workspace settings"
274 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide275 className={PANEL}
A catalogue of model providers, and settings that feel like settings276 >
277 <Link
278 to={`/${slug}`}
279 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"
280 >
281 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
282 <span className="truncate">{slug}</span>
283 </Link>
284 <SidebarGroup title="Settings">
285 {owner && (
286 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
287 General
288 </SidebarLink>
289 )}
290 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
291 Members
292 </SidebarLink>
293 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
294 Billing
295 </SidebarLink>
296 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
297 Integrations
298 </SidebarLink>
Webhooks: every event, to your own addresses, signed and retried299 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
300 Webhooks
301 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs302 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
303 Secrets and variables
304 </SidebarLink>
A catalogue of model providers, and settings that feel like settings305 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
306 Access tokens
307 </SidebarLink>
308 </SidebarGroup>
309 </nav>
310 );
311}
312
Sidebar: no settings flash on the way into a repository, and a better slide313/**
314 * What the repository menu needs. Known from the address before the page's
315 * data arrives, so the menu is right from the first frame; the counts fill
316 * in once it does.
317 */
318type MenuRepo = {
319 namespace: string;
320 name: string;
321 member: boolean;
322 issues?: number;
323 pulls?: number;
324};
325
326function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
327 return (
328 a != null &&
329 b != null &&
330 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
331 a.name.toLowerCase() === b.name.toLowerCase()
332 );
333}
A repository has its own sidebar, as settings do334
335/**
336 * A repository's own menu, which the sidebar slides to while you are in
337 * it, as it does for settings: everything about the repository and nothing
338 * else, with the way back to everything.
339 */
Sidebar: no settings flash on the way into a repository, and a better slide340function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
A repository has its own sidebar, as settings do341 const base = `/${repo.namespace}/${repo.name}`;
342 return (
343 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
344 <Link
345 to="/"
346 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"
347 >
348 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
349 Mission control
350 </Link>
351 <Link
352 to={base}
353 className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60"
354 >
355 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
356 {isPrivate ? <Lock size={13} /> : <BookMarked size={13} />}
357 </span>
358 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
359 <span className="text-faint">{repo.namespace}/</span>
360 <span className="font-semibold text-fg">{repo.name}</span>
361 </span>
362 </Link>
363 <SidebarGroup title="Code">
364 <SidebarLink to={base} end icon={<Code2 size={15} />} also={`${base}/tree`}>
365 Code
366 </SidebarLink>
367 <SidebarLink to={`${base}/commits`} also={`${base}/commit`} icon={<History size={15} />}>
368 Commits
369 </SidebarLink>
370 </SidebarGroup>
371 <SidebarGroup title="Work">
372 <SidebarLink to={`${base}/issues`} icon={<CircleDot size={15} />} count={repo.issues}>
373 Issues
374 </SidebarLink>
375 <SidebarLink to={`${base}/pulls`} also={`${base}/pull`} icon={<GitPullRequest size={15} />} count={repo.pulls}>
376 Pull requests
377 </SidebarLink>
378 {repo.member && (
379 <SidebarLink to={`${base}/plans`} icon={<ListTree size={15} />}>
380 Plan
381 </SidebarLink>
382 )}
383 <SidebarLink to={`${base}/queue`} icon={<Layers size={15} />}>
384 Merge queue
385 </SidebarLink>
386 </SidebarGroup>
387 <SidebarGroup title="Automate">
388 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
389 Actions
390 </SidebarLink>
Deployments: a preview for every pull request, production on g1t.page391 {repo.member && (
392 <SidebarLink to={`${base}/deployments`} icon={<Rocket size={15} />}>
393 Deployments
394 </SidebarLink>
395 )}
A repository has its own sidebar, as settings do396 </SidebarGroup>
397 {repo.member && (
398 <SidebarGroup title="Repository">
399 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
400 Settings
401 </SidebarLink>
402 </SidebarGroup>
403 )}
404 </nav>
405 );
406}
407
Account settings in the same shape as a workspace's408/** Your own settings, as the sidebar shows them on the settings page. */
409function AccountSettingsMenu({ open }: { open: boolean }) {
410 const { hash } = useLocation();
411 const item = (id: string, icon: ReactNode, label: string) => (
412 <Link
413 to={`/settings#${id}`}
414 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
415 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
416 }`}
417 >
418 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
419 {label}
420 </Link>
421 );
422 return (
423 <nav
424 aria-label="Your settings"
425 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide426 className={PANEL}
Account settings in the same shape as a workspace's427 >
428 <Link
429 to="/"
430 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"
431 >
432 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
433 Mission control
434 </Link>
435 <SidebarGroup title="Account">
436 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
437 {item("tokens", <Lock size={15} />, "Access tokens")}
438 {item("applications", <Plug size={15} />, "Connected applications")}
439 </SidebarGroup>
440 </nav>
441 );
442}
443
A Vercel-like sidebar and a new mark444function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell445 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings446 const { pathname } = useLocation();
447 const going = useNavigation().location?.pathname;
448 // On a workspace's settings page, or on the way to one, its settings take
449 // the sidebar over.
450 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's451 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do452 const active = shell.repo;
453 // In a repository, or on the way into one, its own menu takes the sidebar.
454 const target = going ?? pathname;
455 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
456 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
457 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
458 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide459 // The repository the menu is for: the one loaded if it is the one being
460 // gone to, else what the address says, at once.
461 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
462 const menuRepo: MenuRepo | null = targetRepo
463 ? sameRepo(active, targetRepo)
464 ? active
465 : {
466 ...targetRepo,
467 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
468 }
469 : null;
A repository has its own sidebar, as settings do470 // What sits on the far side of the track. Kept while sliding back, so it
471 // does not vanish on the way out.
472 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide473 if (inAccount) side.current = "account";
474 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do475 else if (inRepo) side.current = "repo";
476 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide477 const shown = useRef<MenuRepo | null>(menuRepo);
478 if (menuRepo) shown.current = menuRepo;
479 // Moving between two of the far-side menus (settings to a repository,
480 // one repository to another) crossfades in place instead of sliding.
481 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
482 const lastAway = useRef(away);
483 const lastKey = useRef(detailKey);
484 const swapped = lastAway.current && away && lastKey.current !== detailKey;
485 useEffect(() => {
486 lastAway.current = away;
487 lastKey.current = detailKey;
488 });
Agents as a team: lifecycle, merge queue, billing and a new shell489 // The repository being looked at is listed even when it is someone else's.
490 const listed =
491 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
492 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
493 : shell.repos;
494 return (
495 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark496 {/* The same height and rule as the top bar, so the two read as one line. */}
497 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
498 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
499 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell500 </Link>
A Vercel-like sidebar and a new mark501 <span className="shrink-0 text-line-strong" aria-hidden="true">
502 /
503 </span>
504 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell505 </div>
A Vercel-like sidebar and a new mark506 <div className="px-2 pt-3">
507 <button
508 type="button"
509 onClick={onFind}
510 className="flex h-8 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"
511 >
512 <Search size={14} />
513 <span className="grow text-left">Find…</span>
514 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
515 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell516 </div>
Sidebar: no settings flash on the way into a repository, and a better slide517 <div className="relative min-h-0 grow overflow-hidden">
518 <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 glide519 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell520 <div className="mt-3 space-y-px">
521 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
522 Mission control
523 </SidebarLink>
524 <SidebarLink to="/explore" icon={<Compass size={15} />}>
525 Explore
526 </SidebarLink>
527 </div>
528
529 <SidebarGroup
530 title="Repositories"
531 action={
532 ws && (
533 <Link
534 to={`/new?workspace=${ws.slug}`}
535 aria-label="New repository"
536 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
537 >
538 <Plus size={13} />
539 </Link>
540 )
541 }
542 >
543 {listed.length === 0 && (
544 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
545 )}
546 {listed.map((repo) => {
547 const base = `/${repo.namespace}/${repo.name}`;
548 return (
549 <div key={base}>
550 <SidebarLink
551 to={base}
552 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
553 >
554 <span className="font-mono text-[0.8125rem]">
555 {repo.namespace !== ws?.slug && (
556 <span className="text-faint">{repo.namespace}/</span>
557 )}
558 {repo.name}
559 </span>
560 </SidebarLink>
561 </div>
562 );
563 })}
564 </SidebarGroup>
565 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide566 </div>
Sidebar: no settings flash on the way into a repository, and a better slide567 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
568 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
569 {side.current === "repo" && shown.current ? (
570 <RepoMenu
571 repo={shown.current}
572 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
573 open={inRepo}
574 />
575 ) : side.current === "account" || !ws ? (
576 <AccountSettingsMenu open={inAccount} />
577 ) : (
578 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
579 )}
580 </div>
581 </div>
A catalogue of model providers, and settings that feel like settings582 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell583
Workspace things at the bottom of the sidebar, and on mission control584 {/* The workspace's own things sit at the bottom, by its usage and the account. */}
A Vercel-like sidebar and a new mark585 <div className="space-y-2 p-2">
Workspace things at the bottom of the sidebar, and on mission control586 {ws && (
587 <div className="space-y-px">
A repository has its own sidebar, as settings do588 <p className="px-2 pb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-faint">{ws.slug}</p>
Workspace things at the bottom of the sidebar, and on mission control589 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
590 Usage
591 </SidebarLink>
592 {/* Everything else about the workspace lives in its settings, and only there. */}
593 <SidebarLink
594 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
595 icon={<Settings size={15} />}
596 >
597 Settings
598 </SidebarLink>
599 </div>
600 )}
A Vercel-like sidebar and a new mark601 {ws && <UsageCard slug={ws.slug} shell={shell} />}
602 <AccountMenu user={user} />
603 </div>
604 </div>
605 );
606}
607
608/** Words for the sections a path can end in. */
609const SECTIONS: Record<string, string> = {
610 issues: "Issues",
611 pulls: "Pull requests",
612 queue: "Merge queue",
613 commits: "Commits",
614 plans: "Plan",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs615 actions: "Actions",
Deployments: a preview for every pull request, production on g1t.page616 deployments: "Deployments",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs617 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark618 settings: "Settings",
A catalogue of model providers, and settings that feel like settings619 people: "Members",
A Vercel-like sidebar and a new mark620 tokens: "Access tokens",
621 usage: "Usage",
622 billing: "Billing",
A catalogue of model providers, and settings that feel like settings623 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried624 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark625 tree: "Code",
626 blob: "Code",
627};
628
629/** Where the page is, as a trail of links: workspace / repository / section. */
630function Breadcrumbs({ pathname }: { pathname: string }) {
631 const parts = pathname.split("/").filter(Boolean);
632 const reserved = ["settings", "explore", "new", "search", "workspaces"];
633 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
634 if (reserved.includes(parts[0]!)) {
635 const words: Record<string, string> = {
Account settings in the same shape as a workspace's636 settings: "Account",
A Vercel-like sidebar and a new mark637 explore: "Explore",
638 new: "New repository",
639 search: "Search",
640 workspaces: "New workspace",
641 };
642 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
643 }
644 const [owner, second, third, fourth] = parts;
645 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
646 if (second === "-") {
A catalogue of model providers, and settings that feel like settings647 const page = `/${owner}/-/${third}`;
648 if (third && SETTINGS_PAGE.test(page)) {
649 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
650 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
651 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark652 } else if (second) {
653 const repo = `/${owner}/${second}`;
654 trail.push({ label: second, to: repo, mono: true });
655 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
656 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
657 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
658 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
659 }
660 return (
661 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
662 {trail.map((crumb, index) => (
663 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
664 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell665 <Link
A Vercel-like sidebar and a new mark666 to={crumb.to}
667 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
668 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
669 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell670 >
A Vercel-like sidebar and a new mark671 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell672 </Link>
A Vercel-like sidebar and a new mark673 </span>
674 ))}
675 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell676 );
677}
678
679type Command = { label: string; hint?: string; to: string; icon: ReactNode };
680
681/** Everything the palette can jump to, from what the sidebar already knows. */
682function commandsFor(user: User, shell: ShellData): Command[] {
683 const commands: Command[] = [
684 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
685 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
686 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
687 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
688 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
689 ];
690 const repo = shell.repo;
691 if (repo) {
692 const base = `/${repo.namespace}/${repo.name}`;
693 const name = `${repo.namespace}/${repo.name}`;
694 commands.unshift(
695 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
696 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
697 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
698 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
699 ...(repo.member
700 ? [
701 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
702 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
703 ]
704 : []),
705 );
706 }
707 for (const membership of user.workspaces ?? []) {
708 commands.push(
709 { 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 request710 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings711 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
712 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
713 { 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 shell714 );
715 }
716 for (const listed of shell.repos) {
717 commands.push({
718 label: `${listed.namespace}/${listed.name}`,
719 hint: "Repository",
720 to: `/${listed.namespace}/${listed.name}`,
721 icon: <BookMarked size={15} />,
722 });
723 }
724 return commands;
725}
726
727function CommandPalette({
728 open,
729 onClose,
730 commands,
731}: {
732 open: boolean;
733 onClose: () => void;
734 commands: Command[];
735}) {
736 const navigate = useNavigate();
737 const [query, setQuery] = useState("");
738 const [selected, setSelected] = useState(0);
739 const input = useRef<HTMLInputElement>(null);
740 const matches = useMemo(() => {
741 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
742 return commands
743 .filter((command) => {
744 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
745 return words.every((word) => text.includes(word));
746 })
747 .slice(0, 12);
748 }, [commands, query]);
749
750 useEffect(() => {
751 if (!open) return;
752 setQuery("");
753 setSelected(0);
754 input.current?.focus();
755 }, [open]);
756 useEffect(() => setSelected(0), [query]);
757
758 if (!open) return null;
759 const go = (command: Command | undefined) => {
760 if (!command) return;
761 onClose();
762 navigate(command.to);
763 };
764 return (
765 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
766 <div
767 role="dialog"
768 aria-label="Go to"
769 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
770 onClick={(event) => event.stopPropagation()}
771 >
772 <div className="flex items-center gap-3 border-b border-line px-4">
773 <Search size={16} className="text-faint" />
774 <input
775 ref={input}
776 value={query}
777 onChange={(event) => setQuery(event.target.value)}
778 onKeyDown={(event) => {
779 if (event.key === "ArrowDown") {
780 event.preventDefault();
781 setSelected((index) => Math.min(index + 1, matches.length - 1));
782 } else if (event.key === "ArrowUp") {
783 event.preventDefault();
784 setSelected((index) => Math.max(index - 1, 0));
785 } else if (event.key === "Enter") {
786 event.preventDefault();
787 go(matches[selected]);
788 } else if (event.key === "Escape") {
789 onClose();
790 }
791 }}
792 placeholder="Go to a repository, a page, or an action…"
793 autoComplete="off"
794 data-1p-ignore
795 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"
796 />
797 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
798 </div>
799 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
800 {matches.length === 0 && (
801 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
802 )}
803 {matches.map((command, index) => (
804 <li key={`${command.to}-${command.label}`}>
805 <button
806 type="button"
807 onMouseEnter={() => setSelected(index)}
808 onClick={() => go(command)}
809 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
810 index === selected ? "bg-line text-fg" : "text-muted"
811 }`}
812 >
813 <span className="shrink-0 text-faint">{command.icon}</span>
814 <span className="min-w-0 grow truncate">{command.label}</span>
815 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
816 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
817 </button>
818 </li>
819 ))}
820 </ul>
821 </div>
822 </div>
823 );
824}
825
826/**
827 * A bar across the top of the page while the next one loads, as GitHub
828 * has: it appears at once, creeps towards the end while waiting, then fills
829 * and fades when the page arrives.
830 */
831export function Progress() {
832 const navigation = useNavigation();
833 const busy = navigation.state !== "idle";
834 const [width, setWidth] = useState(0);
835 const [visible, setVisible] = useState(false);
836 useEffect(() => {
837 if (busy) {
838 setVisible(true);
839 setWidth(12);
840 // Slows as it goes, never quite arriving until the page does.
841 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
842 return () => clearInterval(timer);
843 }
844 setWidth((now) => (now > 0 ? 100 : 0));
845 const fade = setTimeout(() => setVisible(false), 250);
846 const reset = setTimeout(() => setWidth(0), 550);
847 return () => {
848 clearTimeout(fade);
849 clearTimeout(reset);
850 };
851 }, [busy]);
852 return (
853 <div
854 aria-hidden="true"
855 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
856 visible ? "opacity-100" : "opacity-0"
857 }`}
858 >
859 <div
860 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
861 style={{ width: `${width}%` }}
862 />
863 </div>
864 );
865}
866
867/**
868 * The signed-in app: a sidebar with the workspace, its repositories and the
869 * sections of the one being looked at; a slim bar with search and the
870 * account; and the page.
871 */
872export function AppShell({
873 user,
874 shell,
875 banner,
876 children,
877}: {
878 user: User;
879 shell: ShellData;
880 banner?: ReactNode;
881 children: ReactNode;
882}) {
883 const submit = useSubmit();
884 const { pathname } = useLocation();
885 const [drawer, setDrawer] = useState(false);
886 const [palette, setPalette] = useState(false);
887 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
888
889 // A new page closes the drawer on small screens.
890 useEffect(() => setDrawer(false), [pathname]);
891 useEffect(() => {
892 const onKey = (event: KeyboardEvent) => {
893 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
894 event.preventDefault();
895 setPalette((open) => !open);
896 }
897 };
898 window.addEventListener("keydown", onKey);
899 return () => window.removeEventListener("keydown", onKey);
900 }, []);
901
902 return (
903 <div className="min-h-screen">
904 <Progress />
905 <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 mark906 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell907 </aside>
908 {drawer && (
909 <div className="fixed inset-0 z-50 lg:hidden">
910 <button
911 type="button"
912 aria-label="Close menu"
913 className="absolute inset-0 bg-black/60"
914 onClick={() => setDrawer(false)}
915 />
916 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
917 <button
918 type="button"
919 aria-label="Close menu"
920 onClick={() => setDrawer(false)}
921 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
922 >
923 <X size={16} />
924 </button>
A Vercel-like sidebar and a new mark925 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell926 </aside>
927 </div>
928 )}
929
Polish: phones, copy boxes, the plan page, the landing page, a real glide930 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
Agents as a team: lifecycle, merge queue, billing and a new shell931 <header className="sticky top-0 z-30 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-3 backdrop-blur sm:px-4">
932 <button
933 type="button"
934 aria-label="Open menu"
935 onClick={() => setDrawer(true)}
936 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
937 >
938 <Menu size={18} />
939 </button>
A Vercel-like sidebar and a new mark940 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell941 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark942 <a
943 href="https://docs.g1t.sh/"
944 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
945 >
946 Docs
947 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell948 <DropdownMenu>
949 <DropdownMenuTrigger
950 aria-label="Create"
A Vercel-like sidebar and a new mark951 className="flex h-8 items-center gap-1.5 rounded-md bg-fg px-2.5 text-[0.8125rem] font-medium text-bg outline-none transition-colors hover:bg-white"
Agents as a team: lifecycle, merge queue, billing and a new shell952 >
A Vercel-like sidebar and a new mark953 <Plus size={14} />
954 New
Agents as a team: lifecycle, merge queue, billing and a new shell955 </DropdownMenuTrigger>
956 <DropdownMenuContent align="end">
957 {shell.repo && (
958 <DropdownMenuItem asChild>
959 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
960 <CircleDot />
961 New issue
962 </Link>
963 </DropdownMenuItem>
964 )}
965 {shell.repo?.member && (
966 <DropdownMenuItem asChild>
967 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
968 <ListTree />
969 Plan work
970 </Link>
971 </DropdownMenuItem>
972 )}
973 <DropdownMenuItem asChild>
974 <Link to="/new">
975 <BookMarked />
976 New repository
977 </Link>
978 </DropdownMenuItem>
979 <DropdownMenuItem asChild>
980 <Link to="/workspaces/new">
981 <Users />
982 New workspace
983 </Link>
984 </DropdownMenuItem>
985 </DropdownMenuContent>
986 </DropdownMenu>
987 </div>
988 </header>
989 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide990 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell991 </div>
992 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
993 </div>
994 );
995}