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

988 lines38,283 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,
Agents as a team: lifecycle, merge queue, billing and a new shell30 X,
31} from "lucide-react";
32import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
34
35import type { User } from "@g1t/contracts";
36import { MICROS_PER_DOLLAR } from "@g1t/contracts";
37
A Vercel-like sidebar and a new mark38import { Mark } from "./logo";
Agents as a team: lifecycle, merge queue, billing and a new shell39import { Avatar } from "./ui";
40import {
41 DropdownMenu,
42 DropdownMenuContent,
43 DropdownMenuItem,
44 DropdownMenuLabel,
45 DropdownMenuSeparator,
46 DropdownMenuTrigger,
47} from "./ui/dropdown-menu";
48
49/** What the sidebar needs, worked out by the root loader for a signed-in person. */
50export type ShellData = {
51 /** The workspace the sidebar is about: the one being looked at, or their first. */
52 workspace: { slug: string; role: "owner" | "member" } | null;
53 /** Its repositories, newest first. */
54 repos: { namespace: string; name: string; isPrivate: boolean }[];
55 /** The repository being looked at, if any, whoever owns it. */
56 repo: {
57 namespace: string;
58 name: string;
59 member: boolean;
60 issues: number;
61 pulls: number;
62 } | null;
63 /** The workspace's agent credit, if billing is on and they may see it. */
64 creditMicros: number | null;
Free while g1t is being built out; agents can check out their own forks65 /** Whether g1t charges nothing for now, while it is being built out. */
66 free?: boolean;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request67 /** What its agents have cost since the start of the month. */
Usage while free is shown at cost; agents get rustfmt and clippy68 /** This month's usage: charged, or at cost while g1t is free. */
69 monthUsageMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell70};
71
72function SidebarLink({
73 to,
74 icon,
75 end,
76 count,
77 also,
78 children,
79}: {
80 to: string;
81 icon: ReactNode;
82 end?: boolean;
83 count?: number;
84 /** Another path prefix under which this link is the current one. */
85 also?: string;
86 children: ReactNode;
87}) {
88 const { pathname } = useLocation();
89 return (
90 <NavLink
91 to={to}
92 end={end}
93 prefetch="intent"
94 className={({ isActive, isPending }) => {
95 const current = isActive || (also != null && pathname.startsWith(also + "/"));
A Vercel-like sidebar and a new mark96 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 shell97 current
98 ? "bg-raised font-medium text-fg"
99 : isPending
100 ? "bg-raised/60 text-fg"
101 : "text-muted hover:bg-raised/60 hover:text-fg"
102 }`;
103 }}
104 >
105 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
106 <span className="min-w-0 grow truncate">{children}</span>
107 {count != null && count > 0 && (
108 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
109 {count}
110 </span>
111 )}
112 </NavLink>
113 );
114}
115
116function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
117 return (
A Vercel-like sidebar and a new mark118 <section className="mt-6">
119 <div className="mb-1 flex h-6 items-center justify-between px-2">
120 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell121 {action}
122 </div>
123 <div className="space-y-px">{children}</div>
124 </section>
125 );
126}
127
128function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
129 const current = shell.workspace?.slug;
130 return (
131 <DropdownMenu>
A Vercel-like sidebar and a new mark132 {/* The name goes to the workspace; only the arrows switch it. */}
133 <Link
134 to={current ? `/${current}` : "/workspaces/new"}
135 className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
136 >
Agents as a team: lifecycle, merge queue, billing and a new shell137 {current ? (
A Vercel-like sidebar and a new mark138 <Avatar name={current} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell139 ) : (
A Vercel-like sidebar and a new mark140 <span className="size-5 rounded-md border border-dashed border-line-strong" />
Agents as a team: lifecycle, merge queue, billing and a new shell141 )}
A Vercel-like sidebar and a new mark142 <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
143 {shell.workspace && (
144 <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">
145 {shell.workspace.role}
Agents as a team: lifecycle, merge queue, billing and a new shell146 </span>
A Vercel-like sidebar and a new mark147 )}
148 </Link>
149 <DropdownMenuTrigger
150 aria-label="Switch workspace"
151 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"
152 >
153 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell154 </DropdownMenuTrigger>
155 <DropdownMenuContent align="start" className="w-60">
156 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
157 {(user.workspaces ?? []).map((membership) => (
158 <DropdownMenuItem asChild key={membership.slug}>
159 <Link to={`/${membership.slug}`}>
160 <Avatar name={membership.slug} size={16} square />
161 <span className="grow">{membership.slug}</span>
162 {membership.slug === current && <Check className="text-accent" />}
163 </Link>
164 </DropdownMenuItem>
165 ))}
166 <DropdownMenuSeparator />
167 <DropdownMenuItem asChild>
168 <Link to="/workspaces/new">
169 <Plus />
170 New workspace
171 </Link>
172 </DropdownMenuItem>
173 </DropdownMenuContent>
174 </DropdownMenu>
175 );
176}
177
A Vercel-like sidebar and a new mark178/** This month's spend against what is left, as Vercel shows a plan's usage. */
179function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
Usage while free is shown at cost; agents get rustfmt and clippy180 if (shell.monthUsageMicros == null) return null;
181 const spent = shell.monthUsageMicros;
A Vercel-like sidebar and a new mark182 const left = shell.creditMicros;
183 const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0;
184 return (
185 <Link
186 to={`/${slug}/-/usage`}
187 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
188 >
189 <span className="flex items-baseline justify-between text-xs">
190 <span className="font-medium text-fg">Usage</span>
191 <span className="text-faint">this month</span>
192 </span>
193 <span className="mt-2 flex items-baseline justify-between">
194 <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 forks195 {shell.free ? (
196 <span className="text-xs text-accent">Free for now</span>
197 ) : (
198 left != null && (
199 <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}>
200 ${(left / MICROS_PER_DOLLAR).toFixed(2)} left
201 </span>
202 )
A Vercel-like sidebar and a new mark203 )}
204 </span>
205 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
206 <span
207 className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`}
208 style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }}
209 />
210 </span>
211 </Link>
212 );
213}
214
215function AccountMenu({ user }: { user: User }) {
216 const submit = useSubmit();
217 return (
218 <DropdownMenu>
219 <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">
220 <Avatar name={user.username} size={22} />
221 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
222 <Ellipsis size={15} className="shrink-0 text-faint" />
223 </DropdownMenuTrigger>
224 <DropdownMenuContent align="start" side="top" className="w-56">
225 <DropdownMenuLabel>
226 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
227 </DropdownMenuLabel>
228 <DropdownMenuSeparator />
229 <DropdownMenuItem asChild>
230 <Link to="/settings">
231 <Settings />
232 Your settings
233 </Link>
234 </DropdownMenuItem>
235 <DropdownMenuItem asChild>
236 <a href="https://docs.g1t.sh/">
237 <BookOpen />
238 Documentation
239 </a>
240 </DropdownMenuItem>
241 <DropdownMenuSeparator />
242 {/* Submitted from here: the menu closes on select, and a button
243 that has left the page cannot submit a form. */}
244 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
245 <LogOut />
246 Sign out
247 </DropdownMenuItem>
248 </DropdownMenuContent>
249 </DropdownMenu>
250 );
251}
252
A catalogue of model providers, and settings that feel like settings253/** A workspace's settings pages, which the sidebar slides over to. */
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs254const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
A catalogue of model providers, and settings that feel like settings255
Polish: phones, copy boxes, the plan page, the landing page, a real glide256/**
Sidebar: no settings flash on the way into a repository, and a better slide257 * The sidebar's menus are two layers, the way a phone pushes a screen: the
258 * one arriving slides in from the right over the full width, while the one
259 * leaving drifts a quarter of the way left and fades, both on one long
Sidebar: the panels really slide260 * ease-out, the fade quicker than the move so the two never blur together.
261 * Going back reverses it.
Polish: phones, copy boxes, the plan page, the landing page, a real glide262 */
Sidebar: no settings flash on the way into a repository, and a better slide263const LAYER =
Sidebar: the panels really slide264 "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 slide265/** One menu, filling its layer. */
266const PANEL = "h-full overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings267
268/** A workspace's settings, as the sidebar shows them in place of everything else. */
269function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
270 return (
271 <nav
272 aria-label="Workspace settings"
273 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide274 className={PANEL}
A catalogue of model providers, and settings that feel like settings275 >
276 <Link
277 to={`/${slug}`}
278 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"
279 >
280 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
281 <span className="truncate">{slug}</span>
282 </Link>
283 <SidebarGroup title="Settings">
284 {owner && (
285 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
286 General
287 </SidebarLink>
288 )}
289 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
290 Members
291 </SidebarLink>
292 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
293 Billing
294 </SidebarLink>
295 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
296 Integrations
297 </SidebarLink>
Webhooks: every event, to your own addresses, signed and retried298 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
299 Webhooks
300 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs301 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
302 Secrets and variables
303 </SidebarLink>
A catalogue of model providers, and settings that feel like settings304 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
305 Access tokens
306 </SidebarLink>
307 </SidebarGroup>
308 </nav>
309 );
310}
311
Sidebar: no settings flash on the way into a repository, and a better slide312/**
313 * What the repository menu needs. Known from the address before the page's
314 * data arrives, so the menu is right from the first frame; the counts fill
315 * in once it does.
316 */
317type MenuRepo = {
318 namespace: string;
319 name: string;
320 member: boolean;
321 issues?: number;
322 pulls?: number;
323};
324
325function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
326 return (
327 a != null &&
328 b != null &&
329 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
330 a.name.toLowerCase() === b.name.toLowerCase()
331 );
332}
A repository has its own sidebar, as settings do333
334/**
335 * A repository's own menu, which the sidebar slides to while you are in
336 * it, as it does for settings: everything about the repository and nothing
337 * else, with the way back to everything.
338 */
Sidebar: no settings flash on the way into a repository, and a better slide339function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
A repository has its own sidebar, as settings do340 const base = `/${repo.namespace}/${repo.name}`;
341 return (
342 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
343 <Link
344 to="/"
345 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"
346 >
347 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
348 Mission control
349 </Link>
350 <Link
351 to={base}
352 className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60"
353 >
354 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
355 {isPrivate ? <Lock size={13} /> : <BookMarked size={13} />}
356 </span>
357 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
358 <span className="text-faint">{repo.namespace}/</span>
359 <span className="font-semibold text-fg">{repo.name}</span>
360 </span>
361 </Link>
362 <SidebarGroup title="Code">
363 <SidebarLink to={base} end icon={<Code2 size={15} />} also={`${base}/tree`}>
364 Code
365 </SidebarLink>
366 <SidebarLink to={`${base}/commits`} also={`${base}/commit`} icon={<History size={15} />}>
367 Commits
368 </SidebarLink>
369 </SidebarGroup>
370 <SidebarGroup title="Work">
371 <SidebarLink to={`${base}/issues`} icon={<CircleDot size={15} />} count={repo.issues}>
372 Issues
373 </SidebarLink>
374 <SidebarLink to={`${base}/pulls`} also={`${base}/pull`} icon={<GitPullRequest size={15} />} count={repo.pulls}>
375 Pull requests
376 </SidebarLink>
377 {repo.member && (
378 <SidebarLink to={`${base}/plans`} icon={<ListTree size={15} />}>
379 Plan
380 </SidebarLink>
381 )}
382 <SidebarLink to={`${base}/queue`} icon={<Layers size={15} />}>
383 Merge queue
384 </SidebarLink>
385 </SidebarGroup>
386 <SidebarGroup title="Automate">
387 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
388 Actions
389 </SidebarLink>
390 </SidebarGroup>
391 {repo.member && (
392 <SidebarGroup title="Repository">
393 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
394 Settings
395 </SidebarLink>
396 </SidebarGroup>
397 )}
398 </nav>
399 );
400}
401
Account settings in the same shape as a workspace's402/** Your own settings, as the sidebar shows them on the settings page. */
403function AccountSettingsMenu({ open }: { open: boolean }) {
404 const { hash } = useLocation();
405 const item = (id: string, icon: ReactNode, label: string) => (
406 <Link
407 to={`/settings#${id}`}
408 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
409 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
410 }`}
411 >
412 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
413 {label}
414 </Link>
415 );
416 return (
417 <nav
418 aria-label="Your settings"
419 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide420 className={PANEL}
Account settings in the same shape as a workspace's421 >
422 <Link
423 to="/"
424 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"
425 >
426 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
427 Mission control
428 </Link>
429 <SidebarGroup title="Account">
430 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
431 {item("tokens", <Lock size={15} />, "Access tokens")}
432 {item("applications", <Plug size={15} />, "Connected applications")}
433 </SidebarGroup>
434 </nav>
435 );
436}
437
A Vercel-like sidebar and a new mark438function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell439 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings440 const { pathname } = useLocation();
441 const going = useNavigation().location?.pathname;
442 // On a workspace's settings page, or on the way to one, its settings take
443 // the sidebar over.
444 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's445 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do446 const active = shell.repo;
447 // In a repository, or on the way into one, its own menu takes the sidebar.
448 const target = going ?? pathname;
449 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
450 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
451 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
452 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide453 // The repository the menu is for: the one loaded if it is the one being
454 // gone to, else what the address says, at once.
455 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
456 const menuRepo: MenuRepo | null = targetRepo
457 ? sameRepo(active, targetRepo)
458 ? active
459 : {
460 ...targetRepo,
461 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
462 }
463 : null;
A repository has its own sidebar, as settings do464 // What sits on the far side of the track. Kept while sliding back, so it
465 // does not vanish on the way out.
466 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide467 if (inAccount) side.current = "account";
468 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do469 else if (inRepo) side.current = "repo";
470 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide471 const shown = useRef<MenuRepo | null>(menuRepo);
472 if (menuRepo) shown.current = menuRepo;
473 // Moving between two of the far-side menus (settings to a repository,
474 // one repository to another) crossfades in place instead of sliding.
475 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
476 const lastAway = useRef(away);
477 const lastKey = useRef(detailKey);
478 const swapped = lastAway.current && away && lastKey.current !== detailKey;
479 useEffect(() => {
480 lastAway.current = away;
481 lastKey.current = detailKey;
482 });
Agents as a team: lifecycle, merge queue, billing and a new shell483 // The repository being looked at is listed even when it is someone else's.
484 const listed =
485 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
486 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
487 : shell.repos;
488 return (
489 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark490 {/* The same height and rule as the top bar, so the two read as one line. */}
491 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
492 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
493 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell494 </Link>
A Vercel-like sidebar and a new mark495 <span className="shrink-0 text-line-strong" aria-hidden="true">
496 /
497 </span>
498 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell499 </div>
A Vercel-like sidebar and a new mark500 <div className="px-2 pt-3">
501 <button
502 type="button"
503 onClick={onFind}
504 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"
505 >
506 <Search size={14} />
507 <span className="grow text-left">Find…</span>
508 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
509 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell510 </div>
Sidebar: no settings flash on the way into a repository, and a better slide511 <div className="relative min-h-0 grow overflow-hidden">
512 <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 glide513 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell514 <div className="mt-3 space-y-px">
515 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
516 Mission control
517 </SidebarLink>
518 <SidebarLink to="/explore" icon={<Compass size={15} />}>
519 Explore
520 </SidebarLink>
521 </div>
522
523 <SidebarGroup
524 title="Repositories"
525 action={
526 ws && (
527 <Link
528 to={`/new?workspace=${ws.slug}`}
529 aria-label="New repository"
530 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
531 >
532 <Plus size={13} />
533 </Link>
534 )
535 }
536 >
537 {listed.length === 0 && (
538 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
539 )}
540 {listed.map((repo) => {
541 const base = `/${repo.namespace}/${repo.name}`;
542 return (
543 <div key={base}>
544 <SidebarLink
545 to={base}
546 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
547 >
548 <span className="font-mono text-[0.8125rem]">
549 {repo.namespace !== ws?.slug && (
550 <span className="text-faint">{repo.namespace}/</span>
551 )}
552 {repo.name}
553 </span>
554 </SidebarLink>
555 </div>
556 );
557 })}
558 </SidebarGroup>
559 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide560 </div>
Sidebar: no settings flash on the way into a repository, and a better slide561 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
562 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
563 {side.current === "repo" && shown.current ? (
564 <RepoMenu
565 repo={shown.current}
566 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
567 open={inRepo}
568 />
569 ) : side.current === "account" || !ws ? (
570 <AccountSettingsMenu open={inAccount} />
571 ) : (
572 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
573 )}
574 </div>
575 </div>
A catalogue of model providers, and settings that feel like settings576 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell577
Workspace things at the bottom of the sidebar, and on mission control578 {/* The workspace's own things sit at the bottom, by its usage and the account. */}
A Vercel-like sidebar and a new mark579 <div className="space-y-2 p-2">
Workspace things at the bottom of the sidebar, and on mission control580 {ws && (
581 <div className="space-y-px">
A repository has its own sidebar, as settings do582 <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 control583 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
584 Usage
585 </SidebarLink>
586 {/* Everything else about the workspace lives in its settings, and only there. */}
587 <SidebarLink
588 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
589 icon={<Settings size={15} />}
590 >
591 Settings
592 </SidebarLink>
593 </div>
594 )}
A Vercel-like sidebar and a new mark595 {ws && <UsageCard slug={ws.slug} shell={shell} />}
596 <AccountMenu user={user} />
597 </div>
598 </div>
599 );
600}
601
602/** Words for the sections a path can end in. */
603const SECTIONS: Record<string, string> = {
604 issues: "Issues",
605 pulls: "Pull requests",
606 queue: "Merge queue",
607 commits: "Commits",
608 plans: "Plan",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs609 actions: "Actions",
610 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark611 settings: "Settings",
A catalogue of model providers, and settings that feel like settings612 people: "Members",
A Vercel-like sidebar and a new mark613 tokens: "Access tokens",
614 usage: "Usage",
615 billing: "Billing",
A catalogue of model providers, and settings that feel like settings616 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried617 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark618 tree: "Code",
619 blob: "Code",
620};
621
622/** Where the page is, as a trail of links: workspace / repository / section. */
623function Breadcrumbs({ pathname }: { pathname: string }) {
624 const parts = pathname.split("/").filter(Boolean);
625 const reserved = ["settings", "explore", "new", "search", "workspaces"];
626 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
627 if (reserved.includes(parts[0]!)) {
628 const words: Record<string, string> = {
Account settings in the same shape as a workspace's629 settings: "Account",
A Vercel-like sidebar and a new mark630 explore: "Explore",
631 new: "New repository",
632 search: "Search",
633 workspaces: "New workspace",
634 };
635 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
636 }
637 const [owner, second, third, fourth] = parts;
638 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
639 if (second === "-") {
A catalogue of model providers, and settings that feel like settings640 const page = `/${owner}/-/${third}`;
641 if (third && SETTINGS_PAGE.test(page)) {
642 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
643 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
644 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark645 } else if (second) {
646 const repo = `/${owner}/${second}`;
647 trail.push({ label: second, to: repo, mono: true });
648 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
649 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
650 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
651 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
652 }
653 return (
654 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
655 {trail.map((crumb, index) => (
656 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
657 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell658 <Link
A Vercel-like sidebar and a new mark659 to={crumb.to}
660 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
661 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
662 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell663 >
A Vercel-like sidebar and a new mark664 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell665 </Link>
A Vercel-like sidebar and a new mark666 </span>
667 ))}
668 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell669 );
670}
671
672type Command = { label: string; hint?: string; to: string; icon: ReactNode };
673
674/** Everything the palette can jump to, from what the sidebar already knows. */
675function commandsFor(user: User, shell: ShellData): Command[] {
676 const commands: Command[] = [
677 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
678 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
679 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
680 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
681 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
682 ];
683 const repo = shell.repo;
684 if (repo) {
685 const base = `/${repo.namespace}/${repo.name}`;
686 const name = `${repo.namespace}/${repo.name}`;
687 commands.unshift(
688 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
689 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
690 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
691 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
692 ...(repo.member
693 ? [
694 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
695 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
696 ]
697 : []),
698 );
699 }
700 for (const membership of user.workspaces ?? []) {
701 commands.push(
702 { 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 request703 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings704 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
705 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
706 { 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 shell707 );
708 }
709 for (const listed of shell.repos) {
710 commands.push({
711 label: `${listed.namespace}/${listed.name}`,
712 hint: "Repository",
713 to: `/${listed.namespace}/${listed.name}`,
714 icon: <BookMarked size={15} />,
715 });
716 }
717 return commands;
718}
719
720function CommandPalette({
721 open,
722 onClose,
723 commands,
724}: {
725 open: boolean;
726 onClose: () => void;
727 commands: Command[];
728}) {
729 const navigate = useNavigate();
730 const [query, setQuery] = useState("");
731 const [selected, setSelected] = useState(0);
732 const input = useRef<HTMLInputElement>(null);
733 const matches = useMemo(() => {
734 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
735 return commands
736 .filter((command) => {
737 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
738 return words.every((word) => text.includes(word));
739 })
740 .slice(0, 12);
741 }, [commands, query]);
742
743 useEffect(() => {
744 if (!open) return;
745 setQuery("");
746 setSelected(0);
747 input.current?.focus();
748 }, [open]);
749 useEffect(() => setSelected(0), [query]);
750
751 if (!open) return null;
752 const go = (command: Command | undefined) => {
753 if (!command) return;
754 onClose();
755 navigate(command.to);
756 };
757 return (
758 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
759 <div
760 role="dialog"
761 aria-label="Go to"
762 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
763 onClick={(event) => event.stopPropagation()}
764 >
765 <div className="flex items-center gap-3 border-b border-line px-4">
766 <Search size={16} className="text-faint" />
767 <input
768 ref={input}
769 value={query}
770 onChange={(event) => setQuery(event.target.value)}
771 onKeyDown={(event) => {
772 if (event.key === "ArrowDown") {
773 event.preventDefault();
774 setSelected((index) => Math.min(index + 1, matches.length - 1));
775 } else if (event.key === "ArrowUp") {
776 event.preventDefault();
777 setSelected((index) => Math.max(index - 1, 0));
778 } else if (event.key === "Enter") {
779 event.preventDefault();
780 go(matches[selected]);
781 } else if (event.key === "Escape") {
782 onClose();
783 }
784 }}
785 placeholder="Go to a repository, a page, or an action…"
786 autoComplete="off"
787 data-1p-ignore
788 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"
789 />
790 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
791 </div>
792 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
793 {matches.length === 0 && (
794 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
795 )}
796 {matches.map((command, index) => (
797 <li key={`${command.to}-${command.label}`}>
798 <button
799 type="button"
800 onMouseEnter={() => setSelected(index)}
801 onClick={() => go(command)}
802 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
803 index === selected ? "bg-line text-fg" : "text-muted"
804 }`}
805 >
806 <span className="shrink-0 text-faint">{command.icon}</span>
807 <span className="min-w-0 grow truncate">{command.label}</span>
808 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
809 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
810 </button>
811 </li>
812 ))}
813 </ul>
814 </div>
815 </div>
816 );
817}
818
819/**
820 * A bar across the top of the page while the next one loads, as GitHub
821 * has: it appears at once, creeps towards the end while waiting, then fills
822 * and fades when the page arrives.
823 */
824export function Progress() {
825 const navigation = useNavigation();
826 const busy = navigation.state !== "idle";
827 const [width, setWidth] = useState(0);
828 const [visible, setVisible] = useState(false);
829 useEffect(() => {
830 if (busy) {
831 setVisible(true);
832 setWidth(12);
833 // Slows as it goes, never quite arriving until the page does.
834 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
835 return () => clearInterval(timer);
836 }
837 setWidth((now) => (now > 0 ? 100 : 0));
838 const fade = setTimeout(() => setVisible(false), 250);
839 const reset = setTimeout(() => setWidth(0), 550);
840 return () => {
841 clearTimeout(fade);
842 clearTimeout(reset);
843 };
844 }, [busy]);
845 return (
846 <div
847 aria-hidden="true"
848 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
849 visible ? "opacity-100" : "opacity-0"
850 }`}
851 >
852 <div
853 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
854 style={{ width: `${width}%` }}
855 />
856 </div>
857 );
858}
859
860/**
861 * The signed-in app: a sidebar with the workspace, its repositories and the
862 * sections of the one being looked at; a slim bar with search and the
863 * account; and the page.
864 */
865export function AppShell({
866 user,
867 shell,
868 banner,
869 children,
870}: {
871 user: User;
872 shell: ShellData;
873 banner?: ReactNode;
874 children: ReactNode;
875}) {
876 const submit = useSubmit();
877 const { pathname } = useLocation();
878 const [drawer, setDrawer] = useState(false);
879 const [palette, setPalette] = useState(false);
880 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
881
882 // A new page closes the drawer on small screens.
883 useEffect(() => setDrawer(false), [pathname]);
884 useEffect(() => {
885 const onKey = (event: KeyboardEvent) => {
886 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
887 event.preventDefault();
888 setPalette((open) => !open);
889 }
890 };
891 window.addEventListener("keydown", onKey);
892 return () => window.removeEventListener("keydown", onKey);
893 }, []);
894
895 return (
896 <div className="min-h-screen">
897 <Progress />
898 <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 mark899 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell900 </aside>
901 {drawer && (
902 <div className="fixed inset-0 z-50 lg:hidden">
903 <button
904 type="button"
905 aria-label="Close menu"
906 className="absolute inset-0 bg-black/60"
907 onClick={() => setDrawer(false)}
908 />
909 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
910 <button
911 type="button"
912 aria-label="Close menu"
913 onClick={() => setDrawer(false)}
914 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
915 >
916 <X size={16} />
917 </button>
A Vercel-like sidebar and a new mark918 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell919 </aside>
920 </div>
921 )}
922
Polish: phones, copy boxes, the plan page, the landing page, a real glide923 <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 shell924 <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">
925 <button
926 type="button"
927 aria-label="Open menu"
928 onClick={() => setDrawer(true)}
929 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
930 >
931 <Menu size={18} />
932 </button>
A Vercel-like sidebar and a new mark933 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell934 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark935 <a
936 href="https://docs.g1t.sh/"
937 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
938 >
939 Docs
940 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell941 <DropdownMenu>
942 <DropdownMenuTrigger
943 aria-label="Create"
A Vercel-like sidebar and a new mark944 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 shell945 >
A Vercel-like sidebar and a new mark946 <Plus size={14} />
947 New
Agents as a team: lifecycle, merge queue, billing and a new shell948 </DropdownMenuTrigger>
949 <DropdownMenuContent align="end">
950 {shell.repo && (
951 <DropdownMenuItem asChild>
952 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
953 <CircleDot />
954 New issue
955 </Link>
956 </DropdownMenuItem>
957 )}
958 {shell.repo?.member && (
959 <DropdownMenuItem asChild>
960 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
961 <ListTree />
962 Plan work
963 </Link>
964 </DropdownMenuItem>
965 )}
966 <DropdownMenuItem asChild>
967 <Link to="/new">
968 <BookMarked />
969 New repository
970 </Link>
971 </DropdownMenuItem>
972 <DropdownMenuItem asChild>
973 <Link to="/workspaces/new">
974 <Users />
975 New workspace
976 </Link>
977 </DropdownMenuItem>
978 </DropdownMenuContent>
979 </DropdownMenu>
980 </div>
981 </header>
982 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide983 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell984 </div>
985 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
986 </div>
987 );
988}