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

987 lines38,215 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. */
68 monthSpentMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell69};
70
71function SidebarLink({
72 to,
73 icon,
74 end,
75 count,
76 also,
77 children,
78}: {
79 to: string;
80 icon: ReactNode;
81 end?: boolean;
82 count?: number;
83 /** Another path prefix under which this link is the current one. */
84 also?: string;
85 children: ReactNode;
86}) {
87 const { pathname } = useLocation();
88 return (
89 <NavLink
90 to={to}
91 end={end}
92 prefetch="intent"
93 className={({ isActive, isPending }) => {
94 const current = isActive || (also != null && pathname.startsWith(also + "/"));
A Vercel-like sidebar and a new mark95 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 shell96 current
97 ? "bg-raised font-medium text-fg"
98 : isPending
99 ? "bg-raised/60 text-fg"
100 : "text-muted hover:bg-raised/60 hover:text-fg"
101 }`;
102 }}
103 >
104 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
105 <span className="min-w-0 grow truncate">{children}</span>
106 {count != null && count > 0 && (
107 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
108 {count}
109 </span>
110 )}
111 </NavLink>
112 );
113}
114
115function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
116 return (
A Vercel-like sidebar and a new mark117 <section className="mt-6">
118 <div className="mb-1 flex h-6 items-center justify-between px-2">
119 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell120 {action}
121 </div>
122 <div className="space-y-px">{children}</div>
123 </section>
124 );
125}
126
127function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
128 const current = shell.workspace?.slug;
129 return (
130 <DropdownMenu>
A Vercel-like sidebar and a new mark131 {/* The name goes to the workspace; only the arrows switch it. */}
132 <Link
133 to={current ? `/${current}` : "/workspaces/new"}
134 className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
135 >
Agents as a team: lifecycle, merge queue, billing and a new shell136 {current ? (
A Vercel-like sidebar and a new mark137 <Avatar name={current} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell138 ) : (
A Vercel-like sidebar and a new mark139 <span className="size-5 rounded-md border border-dashed border-line-strong" />
Agents as a team: lifecycle, merge queue, billing and a new shell140 )}
A Vercel-like sidebar and a new mark141 <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
142 {shell.workspace && (
143 <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">
144 {shell.workspace.role}
Agents as a team: lifecycle, merge queue, billing and a new shell145 </span>
A Vercel-like sidebar and a new mark146 )}
147 </Link>
148 <DropdownMenuTrigger
149 aria-label="Switch workspace"
150 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"
151 >
152 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell153 </DropdownMenuTrigger>
154 <DropdownMenuContent align="start" className="w-60">
155 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
156 {(user.workspaces ?? []).map((membership) => (
157 <DropdownMenuItem asChild key={membership.slug}>
158 <Link to={`/${membership.slug}`}>
159 <Avatar name={membership.slug} size={16} square />
160 <span className="grow">{membership.slug}</span>
161 {membership.slug === current && <Check className="text-accent" />}
162 </Link>
163 </DropdownMenuItem>
164 ))}
165 <DropdownMenuSeparator />
166 <DropdownMenuItem asChild>
167 <Link to="/workspaces/new">
168 <Plus />
169 New workspace
170 </Link>
171 </DropdownMenuItem>
172 </DropdownMenuContent>
173 </DropdownMenu>
174 );
175}
176
A Vercel-like sidebar and a new mark177/** This month's spend against what is left, as Vercel shows a plan's usage. */
178function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
179 if (shell.monthSpentMicros == null) return null;
180 const spent = shell.monthSpentMicros;
181 const left = shell.creditMicros;
182 const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0;
183 return (
184 <Link
185 to={`/${slug}/-/usage`}
186 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
187 >
188 <span className="flex items-baseline justify-between text-xs">
189 <span className="font-medium text-fg">Usage</span>
190 <span className="text-faint">this month</span>
191 </span>
192 <span className="mt-2 flex items-baseline justify-between">
193 <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 forks194 {shell.free ? (
195 <span className="text-xs text-accent">Free for now</span>
196 ) : (
197 left != null && (
198 <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}>
199 ${(left / MICROS_PER_DOLLAR).toFixed(2)} left
200 </span>
201 )
A Vercel-like sidebar and a new mark202 )}
203 </span>
204 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
205 <span
206 className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`}
207 style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }}
208 />
209 </span>
210 </Link>
211 );
212}
213
214function AccountMenu({ user }: { user: User }) {
215 const submit = useSubmit();
216 return (
217 <DropdownMenu>
218 <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">
219 <Avatar name={user.username} size={22} />
220 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
221 <Ellipsis size={15} className="shrink-0 text-faint" />
222 </DropdownMenuTrigger>
223 <DropdownMenuContent align="start" side="top" className="w-56">
224 <DropdownMenuLabel>
225 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
226 </DropdownMenuLabel>
227 <DropdownMenuSeparator />
228 <DropdownMenuItem asChild>
229 <Link to="/settings">
230 <Settings />
231 Your settings
232 </Link>
233 </DropdownMenuItem>
234 <DropdownMenuItem asChild>
235 <a href="https://docs.g1t.sh/">
236 <BookOpen />
237 Documentation
238 </a>
239 </DropdownMenuItem>
240 <DropdownMenuSeparator />
241 {/* Submitted from here: the menu closes on select, and a button
242 that has left the page cannot submit a form. */}
243 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
244 <LogOut />
245 Sign out
246 </DropdownMenuItem>
247 </DropdownMenuContent>
248 </DropdownMenu>
249 );
250}
251
A catalogue of model providers, and settings that feel like settings252/** A workspace's settings pages, which the sidebar slides over to. */
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs253const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
A catalogue of model providers, and settings that feel like settings254
Polish: phones, copy boxes, the plan page, the landing page, a real glide255/**
Sidebar: no settings flash on the way into a repository, and a better slide256 * The sidebar's menus are two layers, the way a phone pushes a screen: the
257 * one arriving slides in from the right over the full width, while the one
258 * leaving drifts a quarter of the way left and fades, both on one long
Sidebar: the panels really slide259 * ease-out, the fade quicker than the move so the two never blur together.
260 * Going back reverses it.
Polish: phones, copy boxes, the plan page, the landing page, a real glide261 */
Sidebar: no settings flash on the way into a repository, and a better slide262const LAYER =
Sidebar: the panels really slide263 "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 slide264/** One menu, filling its layer. */
265const PANEL = "h-full overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings266
267/** A workspace's settings, as the sidebar shows them in place of everything else. */
268function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
269 return (
270 <nav
271 aria-label="Workspace settings"
272 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide273 className={PANEL}
A catalogue of model providers, and settings that feel like settings274 >
275 <Link
276 to={`/${slug}`}
277 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"
278 >
279 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
280 <span className="truncate">{slug}</span>
281 </Link>
282 <SidebarGroup title="Settings">
283 {owner && (
284 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
285 General
286 </SidebarLink>
287 )}
288 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
289 Members
290 </SidebarLink>
291 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
292 Billing
293 </SidebarLink>
294 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
295 Integrations
296 </SidebarLink>
Webhooks: every event, to your own addresses, signed and retried297 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
298 Webhooks
299 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs300 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
301 Secrets and variables
302 </SidebarLink>
A catalogue of model providers, and settings that feel like settings303 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
304 Access tokens
305 </SidebarLink>
306 </SidebarGroup>
307 </nav>
308 );
309}
310
Sidebar: no settings flash on the way into a repository, and a better slide311/**
312 * What the repository menu needs. Known from the address before the page's
313 * data arrives, so the menu is right from the first frame; the counts fill
314 * in once it does.
315 */
316type MenuRepo = {
317 namespace: string;
318 name: string;
319 member: boolean;
320 issues?: number;
321 pulls?: number;
322};
323
324function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
325 return (
326 a != null &&
327 b != null &&
328 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
329 a.name.toLowerCase() === b.name.toLowerCase()
330 );
331}
A repository has its own sidebar, as settings do332
333/**
334 * A repository's own menu, which the sidebar slides to while you are in
335 * it, as it does for settings: everything about the repository and nothing
336 * else, with the way back to everything.
337 */
Sidebar: no settings flash on the way into a repository, and a better slide338function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
A repository has its own sidebar, as settings do339 const base = `/${repo.namespace}/${repo.name}`;
340 return (
341 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
342 <Link
343 to="/"
344 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"
345 >
346 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
347 Mission control
348 </Link>
349 <Link
350 to={base}
351 className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60"
352 >
353 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
354 {isPrivate ? <Lock size={13} /> : <BookMarked size={13} />}
355 </span>
356 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
357 <span className="text-faint">{repo.namespace}/</span>
358 <span className="font-semibold text-fg">{repo.name}</span>
359 </span>
360 </Link>
361 <SidebarGroup title="Code">
362 <SidebarLink to={base} end icon={<Code2 size={15} />} also={`${base}/tree`}>
363 Code
364 </SidebarLink>
365 <SidebarLink to={`${base}/commits`} also={`${base}/commit`} icon={<History size={15} />}>
366 Commits
367 </SidebarLink>
368 </SidebarGroup>
369 <SidebarGroup title="Work">
370 <SidebarLink to={`${base}/issues`} icon={<CircleDot size={15} />} count={repo.issues}>
371 Issues
372 </SidebarLink>
373 <SidebarLink to={`${base}/pulls`} also={`${base}/pull`} icon={<GitPullRequest size={15} />} count={repo.pulls}>
374 Pull requests
375 </SidebarLink>
376 {repo.member && (
377 <SidebarLink to={`${base}/plans`} icon={<ListTree size={15} />}>
378 Plan
379 </SidebarLink>
380 )}
381 <SidebarLink to={`${base}/queue`} icon={<Layers size={15} />}>
382 Merge queue
383 </SidebarLink>
384 </SidebarGroup>
385 <SidebarGroup title="Automate">
386 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
387 Actions
388 </SidebarLink>
389 </SidebarGroup>
390 {repo.member && (
391 <SidebarGroup title="Repository">
392 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
393 Settings
394 </SidebarLink>
395 </SidebarGroup>
396 )}
397 </nav>
398 );
399}
400
Account settings in the same shape as a workspace's401/** Your own settings, as the sidebar shows them on the settings page. */
402function AccountSettingsMenu({ open }: { open: boolean }) {
403 const { hash } = useLocation();
404 const item = (id: string, icon: ReactNode, label: string) => (
405 <Link
406 to={`/settings#${id}`}
407 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
408 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
409 }`}
410 >
411 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
412 {label}
413 </Link>
414 );
415 return (
416 <nav
417 aria-label="Your settings"
418 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide419 className={PANEL}
Account settings in the same shape as a workspace's420 >
421 <Link
422 to="/"
423 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"
424 >
425 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
426 Mission control
427 </Link>
428 <SidebarGroup title="Account">
429 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
430 {item("tokens", <Lock size={15} />, "Access tokens")}
431 {item("applications", <Plug size={15} />, "Connected applications")}
432 </SidebarGroup>
433 </nav>
434 );
435}
436
A Vercel-like sidebar and a new mark437function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell438 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings439 const { pathname } = useLocation();
440 const going = useNavigation().location?.pathname;
441 // On a workspace's settings page, or on the way to one, its settings take
442 // the sidebar over.
443 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's444 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do445 const active = shell.repo;
446 // In a repository, or on the way into one, its own menu takes the sidebar.
447 const target = going ?? pathname;
448 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
449 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
450 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
451 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide452 // The repository the menu is for: the one loaded if it is the one being
453 // gone to, else what the address says, at once.
454 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
455 const menuRepo: MenuRepo | null = targetRepo
456 ? sameRepo(active, targetRepo)
457 ? active
458 : {
459 ...targetRepo,
460 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
461 }
462 : null;
A repository has its own sidebar, as settings do463 // What sits on the far side of the track. Kept while sliding back, so it
464 // does not vanish on the way out.
465 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide466 if (inAccount) side.current = "account";
467 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do468 else if (inRepo) side.current = "repo";
469 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide470 const shown = useRef<MenuRepo | null>(menuRepo);
471 if (menuRepo) shown.current = menuRepo;
472 // Moving between two of the far-side menus (settings to a repository,
473 // one repository to another) crossfades in place instead of sliding.
474 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
475 const lastAway = useRef(away);
476 const lastKey = useRef(detailKey);
477 const swapped = lastAway.current && away && lastKey.current !== detailKey;
478 useEffect(() => {
479 lastAway.current = away;
480 lastKey.current = detailKey;
481 });
Agents as a team: lifecycle, merge queue, billing and a new shell482 // The repository being looked at is listed even when it is someone else's.
483 const listed =
484 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
485 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
486 : shell.repos;
487 return (
488 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark489 {/* The same height and rule as the top bar, so the two read as one line. */}
490 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
491 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
492 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell493 </Link>
A Vercel-like sidebar and a new mark494 <span className="shrink-0 text-line-strong" aria-hidden="true">
495 /
496 </span>
497 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell498 </div>
A Vercel-like sidebar and a new mark499 <div className="px-2 pt-3">
500 <button
501 type="button"
502 onClick={onFind}
503 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"
504 >
505 <Search size={14} />
506 <span className="grow text-left">Find…</span>
507 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
508 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell509 </div>
Sidebar: no settings flash on the way into a repository, and a better slide510 <div className="relative min-h-0 grow overflow-hidden">
511 <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 glide512 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell513 <div className="mt-3 space-y-px">
514 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
515 Mission control
516 </SidebarLink>
517 <SidebarLink to="/explore" icon={<Compass size={15} />}>
518 Explore
519 </SidebarLink>
520 </div>
521
522 <SidebarGroup
523 title="Repositories"
524 action={
525 ws && (
526 <Link
527 to={`/new?workspace=${ws.slug}`}
528 aria-label="New repository"
529 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
530 >
531 <Plus size={13} />
532 </Link>
533 )
534 }
535 >
536 {listed.length === 0 && (
537 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
538 )}
539 {listed.map((repo) => {
540 const base = `/${repo.namespace}/${repo.name}`;
541 return (
542 <div key={base}>
543 <SidebarLink
544 to={base}
545 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
546 >
547 <span className="font-mono text-[0.8125rem]">
548 {repo.namespace !== ws?.slug && (
549 <span className="text-faint">{repo.namespace}/</span>
550 )}
551 {repo.name}
552 </span>
553 </SidebarLink>
554 </div>
555 );
556 })}
557 </SidebarGroup>
558 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide559 </div>
Sidebar: no settings flash on the way into a repository, and a better slide560 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
561 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
562 {side.current === "repo" && shown.current ? (
563 <RepoMenu
564 repo={shown.current}
565 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
566 open={inRepo}
567 />
568 ) : side.current === "account" || !ws ? (
569 <AccountSettingsMenu open={inAccount} />
570 ) : (
571 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
572 )}
573 </div>
574 </div>
A catalogue of model providers, and settings that feel like settings575 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell576
Workspace things at the bottom of the sidebar, and on mission control577 {/* The workspace's own things sit at the bottom, by its usage and the account. */}
A Vercel-like sidebar and a new mark578 <div className="space-y-2 p-2">
Workspace things at the bottom of the sidebar, and on mission control579 {ws && (
580 <div className="space-y-px">
A repository has its own sidebar, as settings do581 <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 control582 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
583 Usage
584 </SidebarLink>
585 {/* Everything else about the workspace lives in its settings, and only there. */}
586 <SidebarLink
587 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
588 icon={<Settings size={15} />}
589 >
590 Settings
591 </SidebarLink>
592 </div>
593 )}
A Vercel-like sidebar and a new mark594 {ws && <UsageCard slug={ws.slug} shell={shell} />}
595 <AccountMenu user={user} />
596 </div>
597 </div>
598 );
599}
600
601/** Words for the sections a path can end in. */
602const SECTIONS: Record<string, string> = {
603 issues: "Issues",
604 pulls: "Pull requests",
605 queue: "Merge queue",
606 commits: "Commits",
607 plans: "Plan",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs608 actions: "Actions",
609 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark610 settings: "Settings",
A catalogue of model providers, and settings that feel like settings611 people: "Members",
A Vercel-like sidebar and a new mark612 tokens: "Access tokens",
613 usage: "Usage",
614 billing: "Billing",
A catalogue of model providers, and settings that feel like settings615 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried616 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark617 tree: "Code",
618 blob: "Code",
619};
620
621/** Where the page is, as a trail of links: workspace / repository / section. */
622function Breadcrumbs({ pathname }: { pathname: string }) {
623 const parts = pathname.split("/").filter(Boolean);
624 const reserved = ["settings", "explore", "new", "search", "workspaces"];
625 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
626 if (reserved.includes(parts[0]!)) {
627 const words: Record<string, string> = {
Account settings in the same shape as a workspace's628 settings: "Account",
A Vercel-like sidebar and a new mark629 explore: "Explore",
630 new: "New repository",
631 search: "Search",
632 workspaces: "New workspace",
633 };
634 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
635 }
636 const [owner, second, third, fourth] = parts;
637 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
638 if (second === "-") {
A catalogue of model providers, and settings that feel like settings639 const page = `/${owner}/-/${third}`;
640 if (third && SETTINGS_PAGE.test(page)) {
641 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
642 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
643 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark644 } else if (second) {
645 const repo = `/${owner}/${second}`;
646 trail.push({ label: second, to: repo, mono: true });
647 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
648 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
649 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
650 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
651 }
652 return (
653 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
654 {trail.map((crumb, index) => (
655 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
656 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell657 <Link
A Vercel-like sidebar and a new mark658 to={crumb.to}
659 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
660 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
661 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell662 >
A Vercel-like sidebar and a new mark663 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell664 </Link>
A Vercel-like sidebar and a new mark665 </span>
666 ))}
667 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell668 );
669}
670
671type Command = { label: string; hint?: string; to: string; icon: ReactNode };
672
673/** Everything the palette can jump to, from what the sidebar already knows. */
674function commandsFor(user: User, shell: ShellData): Command[] {
675 const commands: Command[] = [
676 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
677 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
678 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
679 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
680 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
681 ];
682 const repo = shell.repo;
683 if (repo) {
684 const base = `/${repo.namespace}/${repo.name}`;
685 const name = `${repo.namespace}/${repo.name}`;
686 commands.unshift(
687 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
688 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
689 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
690 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
691 ...(repo.member
692 ? [
693 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
694 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
695 ]
696 : []),
697 );
698 }
699 for (const membership of user.workspaces ?? []) {
700 commands.push(
701 { 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 request702 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings703 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
704 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
705 { 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 shell706 );
707 }
708 for (const listed of shell.repos) {
709 commands.push({
710 label: `${listed.namespace}/${listed.name}`,
711 hint: "Repository",
712 to: `/${listed.namespace}/${listed.name}`,
713 icon: <BookMarked size={15} />,
714 });
715 }
716 return commands;
717}
718
719function CommandPalette({
720 open,
721 onClose,
722 commands,
723}: {
724 open: boolean;
725 onClose: () => void;
726 commands: Command[];
727}) {
728 const navigate = useNavigate();
729 const [query, setQuery] = useState("");
730 const [selected, setSelected] = useState(0);
731 const input = useRef<HTMLInputElement>(null);
732 const matches = useMemo(() => {
733 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
734 return commands
735 .filter((command) => {
736 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
737 return words.every((word) => text.includes(word));
738 })
739 .slice(0, 12);
740 }, [commands, query]);
741
742 useEffect(() => {
743 if (!open) return;
744 setQuery("");
745 setSelected(0);
746 input.current?.focus();
747 }, [open]);
748 useEffect(() => setSelected(0), [query]);
749
750 if (!open) return null;
751 const go = (command: Command | undefined) => {
752 if (!command) return;
753 onClose();
754 navigate(command.to);
755 };
756 return (
757 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
758 <div
759 role="dialog"
760 aria-label="Go to"
761 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
762 onClick={(event) => event.stopPropagation()}
763 >
764 <div className="flex items-center gap-3 border-b border-line px-4">
765 <Search size={16} className="text-faint" />
766 <input
767 ref={input}
768 value={query}
769 onChange={(event) => setQuery(event.target.value)}
770 onKeyDown={(event) => {
771 if (event.key === "ArrowDown") {
772 event.preventDefault();
773 setSelected((index) => Math.min(index + 1, matches.length - 1));
774 } else if (event.key === "ArrowUp") {
775 event.preventDefault();
776 setSelected((index) => Math.max(index - 1, 0));
777 } else if (event.key === "Enter") {
778 event.preventDefault();
779 go(matches[selected]);
780 } else if (event.key === "Escape") {
781 onClose();
782 }
783 }}
784 placeholder="Go to a repository, a page, or an action…"
785 autoComplete="off"
786 data-1p-ignore
787 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"
788 />
789 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
790 </div>
791 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
792 {matches.length === 0 && (
793 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
794 )}
795 {matches.map((command, index) => (
796 <li key={`${command.to}-${command.label}`}>
797 <button
798 type="button"
799 onMouseEnter={() => setSelected(index)}
800 onClick={() => go(command)}
801 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
802 index === selected ? "bg-line text-fg" : "text-muted"
803 }`}
804 >
805 <span className="shrink-0 text-faint">{command.icon}</span>
806 <span className="min-w-0 grow truncate">{command.label}</span>
807 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
808 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
809 </button>
810 </li>
811 ))}
812 </ul>
813 </div>
814 </div>
815 );
816}
817
818/**
819 * A bar across the top of the page while the next one loads, as GitHub
820 * has: it appears at once, creeps towards the end while waiting, then fills
821 * and fades when the page arrives.
822 */
823export function Progress() {
824 const navigation = useNavigation();
825 const busy = navigation.state !== "idle";
826 const [width, setWidth] = useState(0);
827 const [visible, setVisible] = useState(false);
828 useEffect(() => {
829 if (busy) {
830 setVisible(true);
831 setWidth(12);
832 // Slows as it goes, never quite arriving until the page does.
833 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
834 return () => clearInterval(timer);
835 }
836 setWidth((now) => (now > 0 ? 100 : 0));
837 const fade = setTimeout(() => setVisible(false), 250);
838 const reset = setTimeout(() => setWidth(0), 550);
839 return () => {
840 clearTimeout(fade);
841 clearTimeout(reset);
842 };
843 }, [busy]);
844 return (
845 <div
846 aria-hidden="true"
847 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
848 visible ? "opacity-100" : "opacity-0"
849 }`}
850 >
851 <div
852 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
853 style={{ width: `${width}%` }}
854 />
855 </div>
856 );
857}
858
859/**
860 * The signed-in app: a sidebar with the workspace, its repositories and the
861 * sections of the one being looked at; a slim bar with search and the
862 * account; and the page.
863 */
864export function AppShell({
865 user,
866 shell,
867 banner,
868 children,
869}: {
870 user: User;
871 shell: ShellData;
872 banner?: ReactNode;
873 children: ReactNode;
874}) {
875 const submit = useSubmit();
876 const { pathname } = useLocation();
877 const [drawer, setDrawer] = useState(false);
878 const [palette, setPalette] = useState(false);
879 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
880
881 // A new page closes the drawer on small screens.
882 useEffect(() => setDrawer(false), [pathname]);
883 useEffect(() => {
884 const onKey = (event: KeyboardEvent) => {
885 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
886 event.preventDefault();
887 setPalette((open) => !open);
888 }
889 };
890 window.addEventListener("keydown", onKey);
891 return () => window.removeEventListener("keydown", onKey);
892 }, []);
893
894 return (
895 <div className="min-h-screen">
896 <Progress />
897 <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 mark898 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell899 </aside>
900 {drawer && (
901 <div className="fixed inset-0 z-50 lg:hidden">
902 <button
903 type="button"
904 aria-label="Close menu"
905 className="absolute inset-0 bg-black/60"
906 onClick={() => setDrawer(false)}
907 />
908 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
909 <button
910 type="button"
911 aria-label="Close menu"
912 onClick={() => setDrawer(false)}
913 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
914 >
915 <X size={16} />
916 </button>
A Vercel-like sidebar and a new mark917 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell918 </aside>
919 </div>
920 )}
921
Polish: phones, copy boxes, the plan page, the landing page, a real glide922 <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 shell923 <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">
924 <button
925 type="button"
926 aria-label="Open menu"
927 onClick={() => setDrawer(true)}
928 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
929 >
930 <Menu size={18} />
931 </button>
A Vercel-like sidebar and a new mark932 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell933 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark934 <a
935 href="https://docs.g1t.sh/"
936 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
937 >
938 Docs
939 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell940 <DropdownMenu>
941 <DropdownMenuTrigger
942 aria-label="Create"
A Vercel-like sidebar and a new mark943 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 shell944 >
A Vercel-like sidebar and a new mark945 <Plus size={14} />
946 New
Agents as a team: lifecycle, merge queue, billing and a new shell947 </DropdownMenuTrigger>
948 <DropdownMenuContent align="end">
949 {shell.repo && (
950 <DropdownMenuItem asChild>
951 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
952 <CircleDot />
953 New issue
954 </Link>
955 </DropdownMenuItem>
956 )}
957 {shell.repo?.member && (
958 <DropdownMenuItem asChild>
959 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
960 <ListTree />
961 Plan work
962 </Link>
963 </DropdownMenuItem>
964 )}
965 <DropdownMenuItem asChild>
966 <Link to="/new">
967 <BookMarked />
968 New repository
969 </Link>
970 </DropdownMenuItem>
971 <DropdownMenuItem asChild>
972 <Link to="/workspaces/new">
973 <Users />
974 New workspace
975 </Link>
976 </DropdownMenuItem>
977 </DropdownMenuContent>
978 </DropdownMenu>
979 </div>
980 </header>
981 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide982 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell983 </div>
984 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
985 </div>
986 );
987}