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

991 lines38,298 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/**
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
260 * ease-out. 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 =
263 "absolute inset-0 transition-[transform,opacity] duration-[380ms] ease-[cubic-bezier(0.32,0.72,0,1)] will-change-transform motion-reduce:transition-none";
264/** 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 <SidebarLink to={`${base}/automations`} icon={<Zap size={15} />}>
390 Automations
391 </SidebarLink>
392 </SidebarGroup>
393 {repo.member && (
394 <SidebarGroup title="Repository">
395 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
396 Settings
397 </SidebarLink>
398 </SidebarGroup>
399 )}
400 </nav>
401 );
402}
403
Account settings in the same shape as a workspace's404/** Your own settings, as the sidebar shows them on the settings page. */
405function AccountSettingsMenu({ open }: { open: boolean }) {
406 const { hash } = useLocation();
407 const item = (id: string, icon: ReactNode, label: string) => (
408 <Link
409 to={`/settings#${id}`}
410 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
411 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
412 }`}
413 >
414 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
415 {label}
416 </Link>
417 );
418 return (
419 <nav
420 aria-label="Your settings"
421 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide422 className={PANEL}
Account settings in the same shape as a workspace's423 >
424 <Link
425 to="/"
426 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"
427 >
428 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
429 Mission control
430 </Link>
431 <SidebarGroup title="Account">
432 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
433 {item("tokens", <Lock size={15} />, "Access tokens")}
434 {item("applications", <Plug size={15} />, "Connected applications")}
435 </SidebarGroup>
436 </nav>
437 );
438}
439
A Vercel-like sidebar and a new mark440function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell441 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings442 const { pathname } = useLocation();
443 const going = useNavigation().location?.pathname;
444 // On a workspace's settings page, or on the way to one, its settings take
445 // the sidebar over.
446 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's447 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do448 const active = shell.repo;
449 // In a repository, or on the way into one, its own menu takes the sidebar.
450 const target = going ?? pathname;
451 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
452 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
453 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
454 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide455 // The repository the menu is for: the one loaded if it is the one being
456 // gone to, else what the address says, at once.
457 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
458 const menuRepo: MenuRepo | null = targetRepo
459 ? sameRepo(active, targetRepo)
460 ? active
461 : {
462 ...targetRepo,
463 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
464 }
465 : null;
A repository has its own sidebar, as settings do466 // What sits on the far side of the track. Kept while sliding back, so it
467 // does not vanish on the way out.
468 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide469 if (inAccount) side.current = "account";
470 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do471 else if (inRepo) side.current = "repo";
472 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide473 const shown = useRef<MenuRepo | null>(menuRepo);
474 if (menuRepo) shown.current = menuRepo;
475 // Moving between two of the far-side menus (settings to a repository,
476 // one repository to another) crossfades in place instead of sliding.
477 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
478 const lastAway = useRef(away);
479 const lastKey = useRef(detailKey);
480 const swapped = lastAway.current && away && lastKey.current !== detailKey;
481 useEffect(() => {
482 lastAway.current = away;
483 lastKey.current = detailKey;
484 });
Agents as a team: lifecycle, merge queue, billing and a new shell485 // The repository being looked at is listed even when it is someone else's.
486 const listed =
487 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
488 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
489 : shell.repos;
490 return (
491 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark492 {/* The same height and rule as the top bar, so the two read as one line. */}
493 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
494 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
495 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell496 </Link>
A Vercel-like sidebar and a new mark497 <span className="shrink-0 text-line-strong" aria-hidden="true">
498 /
499 </span>
500 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell501 </div>
A Vercel-like sidebar and a new mark502 <div className="px-2 pt-3">
503 <button
504 type="button"
505 onClick={onFind}
506 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"
507 >
508 <Search size={14} />
509 <span className="grow text-left">Find…</span>
510 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
511 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell512 </div>
Sidebar: no settings flash on the way into a repository, and a better slide513 <div className="relative min-h-0 grow overflow-hidden">
514 <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 glide515 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell516 <div className="mt-3 space-y-px">
517 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
518 Mission control
519 </SidebarLink>
520 <SidebarLink to="/explore" icon={<Compass size={15} />}>
521 Explore
522 </SidebarLink>
523 </div>
524
525 <SidebarGroup
526 title="Repositories"
527 action={
528 ws && (
529 <Link
530 to={`/new?workspace=${ws.slug}`}
531 aria-label="New repository"
532 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
533 >
534 <Plus size={13} />
535 </Link>
536 )
537 }
538 >
539 {listed.length === 0 && (
540 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
541 )}
542 {listed.map((repo) => {
543 const base = `/${repo.namespace}/${repo.name}`;
544 return (
545 <div key={base}>
546 <SidebarLink
547 to={base}
548 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
549 >
550 <span className="font-mono text-[0.8125rem]">
551 {repo.namespace !== ws?.slug && (
552 <span className="text-faint">{repo.namespace}/</span>
553 )}
554 {repo.name}
555 </span>
556 </SidebarLink>
557 </div>
558 );
559 })}
560 </SidebarGroup>
561 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide562 </div>
Sidebar: no settings flash on the way into a repository, and a better slide563 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
564 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
565 {side.current === "repo" && shown.current ? (
566 <RepoMenu
567 repo={shown.current}
568 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
569 open={inRepo}
570 />
571 ) : side.current === "account" || !ws ? (
572 <AccountSettingsMenu open={inAccount} />
573 ) : (
574 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
575 )}
576 </div>
577 </div>
A catalogue of model providers, and settings that feel like settings578 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell579
Workspace things at the bottom of the sidebar, and on mission control580 {/* The workspace's own things sit at the bottom, by its usage and the account. */}
A Vercel-like sidebar and a new mark581 <div className="space-y-2 p-2">
Workspace things at the bottom of the sidebar, and on mission control582 {ws && (
583 <div className="space-y-px">
A repository has its own sidebar, as settings do584 <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 control585 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
586 Usage
587 </SidebarLink>
588 {/* Everything else about the workspace lives in its settings, and only there. */}
589 <SidebarLink
590 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
591 icon={<Settings size={15} />}
592 >
593 Settings
594 </SidebarLink>
595 </div>
596 )}
A Vercel-like sidebar and a new mark597 {ws && <UsageCard slug={ws.slug} shell={shell} />}
598 <AccountMenu user={user} />
599 </div>
600 </div>
601 );
602}
603
604/** Words for the sections a path can end in. */
605const SECTIONS: Record<string, string> = {
606 issues: "Issues",
607 pulls: "Pull requests",
608 queue: "Merge queue",
609 commits: "Commits",
610 plans: "Plan",
Automations: rules in .g1t/automations that act when something happens611 automations: "Automations",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs612 actions: "Actions",
613 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark614 settings: "Settings",
A catalogue of model providers, and settings that feel like settings615 people: "Members",
A Vercel-like sidebar and a new mark616 tokens: "Access tokens",
617 usage: "Usage",
618 billing: "Billing",
A catalogue of model providers, and settings that feel like settings619 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried620 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark621 tree: "Code",
622 blob: "Code",
623};
624
625/** Where the page is, as a trail of links: workspace / repository / section. */
626function Breadcrumbs({ pathname }: { pathname: string }) {
627 const parts = pathname.split("/").filter(Boolean);
628 const reserved = ["settings", "explore", "new", "search", "workspaces"];
629 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
630 if (reserved.includes(parts[0]!)) {
631 const words: Record<string, string> = {
Account settings in the same shape as a workspace's632 settings: "Account",
A Vercel-like sidebar and a new mark633 explore: "Explore",
634 new: "New repository",
635 search: "Search",
636 workspaces: "New workspace",
637 };
638 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
639 }
640 const [owner, second, third, fourth] = parts;
641 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
642 if (second === "-") {
A catalogue of model providers, and settings that feel like settings643 const page = `/${owner}/-/${third}`;
644 if (third && SETTINGS_PAGE.test(page)) {
645 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
646 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
647 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark648 } else if (second) {
649 const repo = `/${owner}/${second}`;
650 trail.push({ label: second, to: repo, mono: true });
651 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
652 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
653 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
654 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
655 }
656 return (
657 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
658 {trail.map((crumb, index) => (
659 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
660 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell661 <Link
A Vercel-like sidebar and a new mark662 to={crumb.to}
663 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
664 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
665 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell666 >
A Vercel-like sidebar and a new mark667 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell668 </Link>
A Vercel-like sidebar and a new mark669 </span>
670 ))}
671 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell672 );
673}
674
675type Command = { label: string; hint?: string; to: string; icon: ReactNode };
676
677/** Everything the palette can jump to, from what the sidebar already knows. */
678function commandsFor(user: User, shell: ShellData): Command[] {
679 const commands: Command[] = [
680 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
681 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
682 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
683 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
684 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
685 ];
686 const repo = shell.repo;
687 if (repo) {
688 const base = `/${repo.namespace}/${repo.name}`;
689 const name = `${repo.namespace}/${repo.name}`;
690 commands.unshift(
691 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
692 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
693 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
694 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
695 ...(repo.member
696 ? [
697 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
698 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
699 ]
700 : []),
701 );
702 }
703 for (const membership of user.workspaces ?? []) {
704 commands.push(
705 { 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 request706 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings707 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
708 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
709 { 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 shell710 );
711 }
712 for (const listed of shell.repos) {
713 commands.push({
714 label: `${listed.namespace}/${listed.name}`,
715 hint: "Repository",
716 to: `/${listed.namespace}/${listed.name}`,
717 icon: <BookMarked size={15} />,
718 });
719 }
720 return commands;
721}
722
723function CommandPalette({
724 open,
725 onClose,
726 commands,
727}: {
728 open: boolean;
729 onClose: () => void;
730 commands: Command[];
731}) {
732 const navigate = useNavigate();
733 const [query, setQuery] = useState("");
734 const [selected, setSelected] = useState(0);
735 const input = useRef<HTMLInputElement>(null);
736 const matches = useMemo(() => {
737 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
738 return commands
739 .filter((command) => {
740 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
741 return words.every((word) => text.includes(word));
742 })
743 .slice(0, 12);
744 }, [commands, query]);
745
746 useEffect(() => {
747 if (!open) return;
748 setQuery("");
749 setSelected(0);
750 input.current?.focus();
751 }, [open]);
752 useEffect(() => setSelected(0), [query]);
753
754 if (!open) return null;
755 const go = (command: Command | undefined) => {
756 if (!command) return;
757 onClose();
758 navigate(command.to);
759 };
760 return (
761 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
762 <div
763 role="dialog"
764 aria-label="Go to"
765 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
766 onClick={(event) => event.stopPropagation()}
767 >
768 <div className="flex items-center gap-3 border-b border-line px-4">
769 <Search size={16} className="text-faint" />
770 <input
771 ref={input}
772 value={query}
773 onChange={(event) => setQuery(event.target.value)}
774 onKeyDown={(event) => {
775 if (event.key === "ArrowDown") {
776 event.preventDefault();
777 setSelected((index) => Math.min(index + 1, matches.length - 1));
778 } else if (event.key === "ArrowUp") {
779 event.preventDefault();
780 setSelected((index) => Math.max(index - 1, 0));
781 } else if (event.key === "Enter") {
782 event.preventDefault();
783 go(matches[selected]);
784 } else if (event.key === "Escape") {
785 onClose();
786 }
787 }}
788 placeholder="Go to a repository, a page, or an action…"
789 autoComplete="off"
790 data-1p-ignore
791 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"
792 />
793 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
794 </div>
795 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
796 {matches.length === 0 && (
797 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
798 )}
799 {matches.map((command, index) => (
800 <li key={`${command.to}-${command.label}`}>
801 <button
802 type="button"
803 onMouseEnter={() => setSelected(index)}
804 onClick={() => go(command)}
805 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
806 index === selected ? "bg-line text-fg" : "text-muted"
807 }`}
808 >
809 <span className="shrink-0 text-faint">{command.icon}</span>
810 <span className="min-w-0 grow truncate">{command.label}</span>
811 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
812 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
813 </button>
814 </li>
815 ))}
816 </ul>
817 </div>
818 </div>
819 );
820}
821
822/**
823 * A bar across the top of the page while the next one loads, as GitHub
824 * has: it appears at once, creeps towards the end while waiting, then fills
825 * and fades when the page arrives.
826 */
827export function Progress() {
828 const navigation = useNavigation();
829 const busy = navigation.state !== "idle";
830 const [width, setWidth] = useState(0);
831 const [visible, setVisible] = useState(false);
832 useEffect(() => {
833 if (busy) {
834 setVisible(true);
835 setWidth(12);
836 // Slows as it goes, never quite arriving until the page does.
837 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
838 return () => clearInterval(timer);
839 }
840 setWidth((now) => (now > 0 ? 100 : 0));
841 const fade = setTimeout(() => setVisible(false), 250);
842 const reset = setTimeout(() => setWidth(0), 550);
843 return () => {
844 clearTimeout(fade);
845 clearTimeout(reset);
846 };
847 }, [busy]);
848 return (
849 <div
850 aria-hidden="true"
851 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
852 visible ? "opacity-100" : "opacity-0"
853 }`}
854 >
855 <div
856 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
857 style={{ width: `${width}%` }}
858 />
859 </div>
860 );
861}
862
863/**
864 * The signed-in app: a sidebar with the workspace, its repositories and the
865 * sections of the one being looked at; a slim bar with search and the
866 * account; and the page.
867 */
868export function AppShell({
869 user,
870 shell,
871 banner,
872 children,
873}: {
874 user: User;
875 shell: ShellData;
876 banner?: ReactNode;
877 children: ReactNode;
878}) {
879 const submit = useSubmit();
880 const { pathname } = useLocation();
881 const [drawer, setDrawer] = useState(false);
882 const [palette, setPalette] = useState(false);
883 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
884
885 // A new page closes the drawer on small screens.
886 useEffect(() => setDrawer(false), [pathname]);
887 useEffect(() => {
888 const onKey = (event: KeyboardEvent) => {
889 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
890 event.preventDefault();
891 setPalette((open) => !open);
892 }
893 };
894 window.addEventListener("keydown", onKey);
895 return () => window.removeEventListener("keydown", onKey);
896 }, []);
897
898 return (
899 <div className="min-h-screen">
900 <Progress />
901 <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 mark902 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell903 </aside>
904 {drawer && (
905 <div className="fixed inset-0 z-50 lg:hidden">
906 <button
907 type="button"
908 aria-label="Close menu"
909 className="absolute inset-0 bg-black/60"
910 onClick={() => setDrawer(false)}
911 />
912 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
913 <button
914 type="button"
915 aria-label="Close menu"
916 onClick={() => setDrawer(false)}
917 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
918 >
919 <X size={16} />
920 </button>
A Vercel-like sidebar and a new mark921 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell922 </aside>
923 </div>
924 )}
925
Polish: phones, copy boxes, the plan page, the landing page, a real glide926 <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 shell927 <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">
928 <button
929 type="button"
930 aria-label="Open menu"
931 onClick={() => setDrawer(true)}
932 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
933 >
934 <Menu size={18} />
935 </button>
A Vercel-like sidebar and a new mark936 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell937 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark938 <a
939 href="https://docs.g1t.sh/"
940 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
941 >
942 Docs
943 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell944 <DropdownMenu>
945 <DropdownMenuTrigger
946 aria-label="Create"
A Vercel-like sidebar and a new mark947 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 shell948 >
A Vercel-like sidebar and a new mark949 <Plus size={14} />
950 New
Agents as a team: lifecycle, merge queue, billing and a new shell951 </DropdownMenuTrigger>
952 <DropdownMenuContent align="end">
953 {shell.repo && (
954 <DropdownMenuItem asChild>
955 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
956 <CircleDot />
957 New issue
958 </Link>
959 </DropdownMenuItem>
960 )}
961 {shell.repo?.member && (
962 <DropdownMenuItem asChild>
963 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
964 <ListTree />
965 Plan work
966 </Link>
967 </DropdownMenuItem>
968 )}
969 <DropdownMenuItem asChild>
970 <Link to="/new">
971 <BookMarked />
972 New repository
973 </Link>
974 </DropdownMenuItem>
975 <DropdownMenuItem asChild>
976 <Link to="/workspaces/new">
977 <Users />
978 New workspace
979 </Link>
980 </DropdownMenuItem>
981 </DropdownMenuContent>
982 </DropdownMenu>
983 </div>
984 </header>
985 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide986 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell987 </div>
988 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
989 </div>
990 );
991}