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

944 lines36,433 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,
Automations: rules in .g1t/automations that act when something happens30 Zap,
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. */
69 monthSpentMicros: 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 }) {
180 if (shell.monthSpentMicros == null) return null;
181 const spent = shell.monthSpentMicros;
182 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/**
257 * The sidebar's menus sit side by side on one track, and the track slides:
258 * the main menu leaves to the left as the settings arrive from the right,
259 * together, with a long ease-out, the way a phone pushes a screen.
260 */
A quicker settings slide: the same curve over 380ms261const TRACK = "flex h-full w-[200%] transition-transform duration-[380ms] ease-[cubic-bezier(0.45,0,0.15,1)] will-change-transform motion-reduce:transition-none";
Polish: phones, copy boxes, the plan page, the landing page, a real glide262/** One menu on the track. */
263const PANEL = "h-full w-1/2 shrink-0 overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings264
265/** A workspace's settings, as the sidebar shows them in place of everything else. */
266function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
267 return (
268 <nav
269 aria-label="Workspace settings"
270 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide271 className={PANEL}
A catalogue of model providers, and settings that feel like settings272 >
273 <Link
274 to={`/${slug}`}
275 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"
276 >
277 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
278 <span className="truncate">{slug}</span>
279 </Link>
280 <SidebarGroup title="Settings">
281 {owner && (
282 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
283 General
284 </SidebarLink>
285 )}
286 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
287 Members
288 </SidebarLink>
289 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
290 Billing
291 </SidebarLink>
292 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
293 Integrations
294 </SidebarLink>
Webhooks: every event, to your own addresses, signed and retried295 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
296 Webhooks
297 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs298 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
299 Secrets and variables
300 </SidebarLink>
A catalogue of model providers, and settings that feel like settings301 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
302 Access tokens
303 </SidebarLink>
304 </SidebarGroup>
305 </nav>
306 );
307}
308
A repository has its own sidebar, as settings do309type ActiveRepo = NonNullable<ShellData["repo"]>;
310
311/**
312 * A repository's own menu, which the sidebar slides to while you are in
313 * it, as it does for settings: everything about the repository and nothing
314 * else, with the way back to everything.
315 */
316function RepoMenu({ repo, isPrivate, open }: { repo: ActiveRepo; isPrivate: boolean; open: boolean }) {
317 const base = `/${repo.namespace}/${repo.name}`;
318 return (
319 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
320 <Link
321 to="/"
322 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"
323 >
324 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
325 Mission control
326 </Link>
327 <Link
328 to={base}
329 className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60"
330 >
331 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
332 {isPrivate ? <Lock size={13} /> : <BookMarked size={13} />}
333 </span>
334 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
335 <span className="text-faint">{repo.namespace}/</span>
336 <span className="font-semibold text-fg">{repo.name}</span>
337 </span>
338 </Link>
339 <SidebarGroup title="Code">
340 <SidebarLink to={base} end icon={<Code2 size={15} />} also={`${base}/tree`}>
341 Code
342 </SidebarLink>
343 <SidebarLink to={`${base}/commits`} also={`${base}/commit`} icon={<History size={15} />}>
344 Commits
345 </SidebarLink>
346 </SidebarGroup>
347 <SidebarGroup title="Work">
348 <SidebarLink to={`${base}/issues`} icon={<CircleDot size={15} />} count={repo.issues}>
349 Issues
350 </SidebarLink>
351 <SidebarLink to={`${base}/pulls`} also={`${base}/pull`} icon={<GitPullRequest size={15} />} count={repo.pulls}>
352 Pull requests
353 </SidebarLink>
354 {repo.member && (
355 <SidebarLink to={`${base}/plans`} icon={<ListTree size={15} />}>
356 Plan
357 </SidebarLink>
358 )}
359 <SidebarLink to={`${base}/queue`} icon={<Layers size={15} />}>
360 Merge queue
361 </SidebarLink>
362 </SidebarGroup>
363 <SidebarGroup title="Automate">
364 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
365 Actions
366 </SidebarLink>
367 <SidebarLink to={`${base}/automations`} icon={<Zap size={15} />}>
368 Automations
369 </SidebarLink>
370 </SidebarGroup>
371 {repo.member && (
372 <SidebarGroup title="Repository">
373 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
374 Settings
375 </SidebarLink>
376 </SidebarGroup>
377 )}
378 </nav>
379 );
380}
381
Account settings in the same shape as a workspace's382/** Your own settings, as the sidebar shows them on the settings page. */
383function AccountSettingsMenu({ open }: { open: boolean }) {
384 const { hash } = useLocation();
385 const item = (id: string, icon: ReactNode, label: string) => (
386 <Link
387 to={`/settings#${id}`}
388 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
389 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
390 }`}
391 >
392 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
393 {label}
394 </Link>
395 );
396 return (
397 <nav
398 aria-label="Your settings"
399 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide400 className={PANEL}
Account settings in the same shape as a workspace's401 >
402 <Link
403 to="/"
404 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"
405 >
406 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
407 Mission control
408 </Link>
409 <SidebarGroup title="Account">
410 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
411 {item("tokens", <Lock size={15} />, "Access tokens")}
412 {item("applications", <Plug size={15} />, "Connected applications")}
413 </SidebarGroup>
414 </nav>
415 );
416}
417
A Vercel-like sidebar and a new mark418function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell419 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings420 const { pathname } = useLocation();
421 const going = useNavigation().location?.pathname;
422 // On a workspace's settings page, or on the way to one, its settings take
423 // the sidebar over.
424 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's425 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do426 const active = shell.repo;
427 // In a repository, or on the way into one, its own menu takes the sidebar.
428 const target = going ?? pathname;
429 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
430 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
431 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
432 const away = inSettings || inAccount || inRepo;
433 // What sits on the far side of the track. Kept while sliding back, so it
434 // does not vanish on the way out.
435 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide436 if (inAccount) side.current = "account";
437 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do438 else if (inRepo) side.current = "repo";
439 // The repository last shown, kept for the slide back.
440 const shown = useRef(active);
441 if (active) shown.current = active;
Agents as a team: lifecycle, merge queue, billing and a new shell442 // The repository being looked at is listed even when it is someone else's.
443 const listed =
444 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
445 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
446 : shell.repos;
447 return (
448 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark449 {/* The same height and rule as the top bar, so the two read as one line. */}
450 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
451 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
452 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell453 </Link>
A Vercel-like sidebar and a new mark454 <span className="shrink-0 text-line-strong" aria-hidden="true">
455 /
456 </span>
457 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell458 </div>
A Vercel-like sidebar and a new mark459 <div className="px-2 pt-3">
460 <button
461 type="button"
462 onClick={onFind}
463 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"
464 >
465 <Search size={14} />
466 <span className="grow text-left">Find…</span>
467 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
468 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell469 </div>
Polish: phones, copy boxes, the plan page, the landing page, a real glide470 <div className="min-h-0 grow overflow-hidden">
471 <div className={`${TRACK} ${away ? "-translate-x-1/2" : "translate-x-0"}`}>
472 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell473 <div className="mt-3 space-y-px">
474 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
475 Mission control
476 </SidebarLink>
477 <SidebarLink to="/explore" icon={<Compass size={15} />}>
478 Explore
479 </SidebarLink>
480 </div>
481
482 <SidebarGroup
483 title="Repositories"
484 action={
485 ws && (
486 <Link
487 to={`/new?workspace=${ws.slug}`}
488 aria-label="New repository"
489 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
490 >
491 <Plus size={13} />
492 </Link>
493 )
494 }
495 >
496 {listed.length === 0 && (
497 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
498 )}
499 {listed.map((repo) => {
500 const base = `/${repo.namespace}/${repo.name}`;
501 return (
502 <div key={base}>
503 <SidebarLink
504 to={base}
505 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
506 >
507 <span className="font-mono text-[0.8125rem]">
508 {repo.namespace !== ws?.slug && (
509 <span className="text-faint">{repo.namespace}/</span>
510 )}
511 {repo.name}
512 </span>
513 </SidebarLink>
514 </div>
515 );
516 })}
517 </SidebarGroup>
518 </nav>
A repository has its own sidebar, as settings do519 {side.current === "repo" && shown.current ? (
520 <RepoMenu
521 repo={shown.current}
522 isPrivate={shell.repos.some((repo) => repo.namespace === shown.current!.namespace && repo.name === shown.current!.name && repo.isPrivate)}
523 open={inRepo}
524 />
525 ) : side.current === "account" || !ws ? (
Polish: phones, copy boxes, the plan page, the landing page, a real glide526 <AccountSettingsMenu open={inAccount} />
527 ) : (
528 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
529 )}
530 </div>
A catalogue of model providers, and settings that feel like settings531 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell532
Workspace things at the bottom of the sidebar, and on mission control533 {/* The workspace's own things sit at the bottom, by its usage and the account. */}
A Vercel-like sidebar and a new mark534 <div className="space-y-2 p-2">
Workspace things at the bottom of the sidebar, and on mission control535 {ws && (
536 <div className="space-y-px">
A repository has its own sidebar, as settings do537 <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 control538 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
539 Usage
540 </SidebarLink>
541 {/* Everything else about the workspace lives in its settings, and only there. */}
542 <SidebarLink
543 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
544 icon={<Settings size={15} />}
545 >
546 Settings
547 </SidebarLink>
548 </div>
549 )}
A Vercel-like sidebar and a new mark550 {ws && <UsageCard slug={ws.slug} shell={shell} />}
551 <AccountMenu user={user} />
552 </div>
553 </div>
554 );
555}
556
557/** Words for the sections a path can end in. */
558const SECTIONS: Record<string, string> = {
559 issues: "Issues",
560 pulls: "Pull requests",
561 queue: "Merge queue",
562 commits: "Commits",
563 plans: "Plan",
Automations: rules in .g1t/automations that act when something happens564 automations: "Automations",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs565 actions: "Actions",
566 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark567 settings: "Settings",
A catalogue of model providers, and settings that feel like settings568 people: "Members",
A Vercel-like sidebar and a new mark569 tokens: "Access tokens",
570 usage: "Usage",
571 billing: "Billing",
A catalogue of model providers, and settings that feel like settings572 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried573 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark574 tree: "Code",
575 blob: "Code",
576};
577
578/** Where the page is, as a trail of links: workspace / repository / section. */
579function Breadcrumbs({ pathname }: { pathname: string }) {
580 const parts = pathname.split("/").filter(Boolean);
581 const reserved = ["settings", "explore", "new", "search", "workspaces"];
582 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
583 if (reserved.includes(parts[0]!)) {
584 const words: Record<string, string> = {
Account settings in the same shape as a workspace's585 settings: "Account",
A Vercel-like sidebar and a new mark586 explore: "Explore",
587 new: "New repository",
588 search: "Search",
589 workspaces: "New workspace",
590 };
591 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
592 }
593 const [owner, second, third, fourth] = parts;
594 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
595 if (second === "-") {
A catalogue of model providers, and settings that feel like settings596 const page = `/${owner}/-/${third}`;
597 if (third && SETTINGS_PAGE.test(page)) {
598 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
599 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
600 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark601 } else if (second) {
602 const repo = `/${owner}/${second}`;
603 trail.push({ label: second, to: repo, mono: true });
604 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
605 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
606 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
607 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
608 }
609 return (
610 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
611 {trail.map((crumb, index) => (
612 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
613 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell614 <Link
A Vercel-like sidebar and a new mark615 to={crumb.to}
616 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
617 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
618 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell619 >
A Vercel-like sidebar and a new mark620 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell621 </Link>
A Vercel-like sidebar and a new mark622 </span>
623 ))}
624 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell625 );
626}
627
628type Command = { label: string; hint?: string; to: string; icon: ReactNode };
629
630/** Everything the palette can jump to, from what the sidebar already knows. */
631function commandsFor(user: User, shell: ShellData): Command[] {
632 const commands: Command[] = [
633 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
634 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
635 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
636 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
637 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
638 ];
639 const repo = shell.repo;
640 if (repo) {
641 const base = `/${repo.namespace}/${repo.name}`;
642 const name = `${repo.namespace}/${repo.name}`;
643 commands.unshift(
644 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
645 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
646 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
647 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
648 ...(repo.member
649 ? [
650 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
651 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
652 ]
653 : []),
654 );
655 }
656 for (const membership of user.workspaces ?? []) {
657 commands.push(
658 { 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 request659 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings660 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
661 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
662 { 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 shell663 );
664 }
665 for (const listed of shell.repos) {
666 commands.push({
667 label: `${listed.namespace}/${listed.name}`,
668 hint: "Repository",
669 to: `/${listed.namespace}/${listed.name}`,
670 icon: <BookMarked size={15} />,
671 });
672 }
673 return commands;
674}
675
676function CommandPalette({
677 open,
678 onClose,
679 commands,
680}: {
681 open: boolean;
682 onClose: () => void;
683 commands: Command[];
684}) {
685 const navigate = useNavigate();
686 const [query, setQuery] = useState("");
687 const [selected, setSelected] = useState(0);
688 const input = useRef<HTMLInputElement>(null);
689 const matches = useMemo(() => {
690 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
691 return commands
692 .filter((command) => {
693 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
694 return words.every((word) => text.includes(word));
695 })
696 .slice(0, 12);
697 }, [commands, query]);
698
699 useEffect(() => {
700 if (!open) return;
701 setQuery("");
702 setSelected(0);
703 input.current?.focus();
704 }, [open]);
705 useEffect(() => setSelected(0), [query]);
706
707 if (!open) return null;
708 const go = (command: Command | undefined) => {
709 if (!command) return;
710 onClose();
711 navigate(command.to);
712 };
713 return (
714 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
715 <div
716 role="dialog"
717 aria-label="Go to"
718 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
719 onClick={(event) => event.stopPropagation()}
720 >
721 <div className="flex items-center gap-3 border-b border-line px-4">
722 <Search size={16} className="text-faint" />
723 <input
724 ref={input}
725 value={query}
726 onChange={(event) => setQuery(event.target.value)}
727 onKeyDown={(event) => {
728 if (event.key === "ArrowDown") {
729 event.preventDefault();
730 setSelected((index) => Math.min(index + 1, matches.length - 1));
731 } else if (event.key === "ArrowUp") {
732 event.preventDefault();
733 setSelected((index) => Math.max(index - 1, 0));
734 } else if (event.key === "Enter") {
735 event.preventDefault();
736 go(matches[selected]);
737 } else if (event.key === "Escape") {
738 onClose();
739 }
740 }}
741 placeholder="Go to a repository, a page, or an action…"
742 autoComplete="off"
743 data-1p-ignore
744 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"
745 />
746 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
747 </div>
748 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
749 {matches.length === 0 && (
750 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
751 )}
752 {matches.map((command, index) => (
753 <li key={`${command.to}-${command.label}`}>
754 <button
755 type="button"
756 onMouseEnter={() => setSelected(index)}
757 onClick={() => go(command)}
758 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
759 index === selected ? "bg-line text-fg" : "text-muted"
760 }`}
761 >
762 <span className="shrink-0 text-faint">{command.icon}</span>
763 <span className="min-w-0 grow truncate">{command.label}</span>
764 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
765 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
766 </button>
767 </li>
768 ))}
769 </ul>
770 </div>
771 </div>
772 );
773}
774
775/**
776 * A bar across the top of the page while the next one loads, as GitHub
777 * has: it appears at once, creeps towards the end while waiting, then fills
778 * and fades when the page arrives.
779 */
780export function Progress() {
781 const navigation = useNavigation();
782 const busy = navigation.state !== "idle";
783 const [width, setWidth] = useState(0);
784 const [visible, setVisible] = useState(false);
785 useEffect(() => {
786 if (busy) {
787 setVisible(true);
788 setWidth(12);
789 // Slows as it goes, never quite arriving until the page does.
790 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
791 return () => clearInterval(timer);
792 }
793 setWidth((now) => (now > 0 ? 100 : 0));
794 const fade = setTimeout(() => setVisible(false), 250);
795 const reset = setTimeout(() => setWidth(0), 550);
796 return () => {
797 clearTimeout(fade);
798 clearTimeout(reset);
799 };
800 }, [busy]);
801 return (
802 <div
803 aria-hidden="true"
804 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
805 visible ? "opacity-100" : "opacity-0"
806 }`}
807 >
808 <div
809 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
810 style={{ width: `${width}%` }}
811 />
812 </div>
813 );
814}
815
816/**
817 * The signed-in app: a sidebar with the workspace, its repositories and the
818 * sections of the one being looked at; a slim bar with search and the
819 * account; and the page.
820 */
821export function AppShell({
822 user,
823 shell,
824 banner,
825 children,
826}: {
827 user: User;
828 shell: ShellData;
829 banner?: ReactNode;
830 children: ReactNode;
831}) {
832 const submit = useSubmit();
833 const { pathname } = useLocation();
834 const [drawer, setDrawer] = useState(false);
835 const [palette, setPalette] = useState(false);
836 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
837
838 // A new page closes the drawer on small screens.
839 useEffect(() => setDrawer(false), [pathname]);
840 useEffect(() => {
841 const onKey = (event: KeyboardEvent) => {
842 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
843 event.preventDefault();
844 setPalette((open) => !open);
845 }
846 };
847 window.addEventListener("keydown", onKey);
848 return () => window.removeEventListener("keydown", onKey);
849 }, []);
850
851 return (
852 <div className="min-h-screen">
853 <Progress />
854 <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 mark855 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell856 </aside>
857 {drawer && (
858 <div className="fixed inset-0 z-50 lg:hidden">
859 <button
860 type="button"
861 aria-label="Close menu"
862 className="absolute inset-0 bg-black/60"
863 onClick={() => setDrawer(false)}
864 />
865 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
866 <button
867 type="button"
868 aria-label="Close menu"
869 onClick={() => setDrawer(false)}
870 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
871 >
872 <X size={16} />
873 </button>
A Vercel-like sidebar and a new mark874 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell875 </aside>
876 </div>
877 )}
878
Polish: phones, copy boxes, the plan page, the landing page, a real glide879 <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 shell880 <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">
881 <button
882 type="button"
883 aria-label="Open menu"
884 onClick={() => setDrawer(true)}
885 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
886 >
887 <Menu size={18} />
888 </button>
A Vercel-like sidebar and a new mark889 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell890 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark891 <a
892 href="https://docs.g1t.sh/"
893 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
894 >
895 Docs
896 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell897 <DropdownMenu>
898 <DropdownMenuTrigger
899 aria-label="Create"
A Vercel-like sidebar and a new mark900 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 shell901 >
A Vercel-like sidebar and a new mark902 <Plus size={14} />
903 New
Agents as a team: lifecycle, merge queue, billing and a new shell904 </DropdownMenuTrigger>
905 <DropdownMenuContent align="end">
906 {shell.repo && (
907 <DropdownMenuItem asChild>
908 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
909 <CircleDot />
910 New issue
911 </Link>
912 </DropdownMenuItem>
913 )}
914 {shell.repo?.member && (
915 <DropdownMenuItem asChild>
916 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
917 <ListTree />
918 Plan work
919 </Link>
920 </DropdownMenuItem>
921 )}
922 <DropdownMenuItem asChild>
923 <Link to="/new">
924 <BookMarked />
925 New repository
926 </Link>
927 </DropdownMenuItem>
928 <DropdownMenuItem asChild>
929 <Link to="/workspaces/new">
930 <Users />
931 New workspace
932 </Link>
933 </DropdownMenuItem>
934 </DropdownMenuContent>
935 </DropdownMenu>
936 </div>
937 </header>
938 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide939 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell940 </div>
941 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
942 </div>
943 );
944}