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

992 lines38,371 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
Sidebar: the panels really slide260 * ease-out, the fade quicker than the move so the two never blur together.
261 * Going back reverses it.
Polish: phones, copy boxes, the plan page, the landing page, a real glide262 */
Sidebar: no settings flash on the way into a repository, and a better slide263const LAYER =
Sidebar: the panels really slide264 "absolute inset-0 [transition:translate_380ms_cubic-bezier(0.32,0.72,0,1),opacity_220ms_ease-out] will-change-[translate,opacity] motion-reduce:transition-none";
Sidebar: no settings flash on the way into a repository, and a better slide265/** One menu, filling its layer. */
266const PANEL = "h-full overflow-y-auto px-2 pb-4";
A catalogue of model providers, and settings that feel like settings267
268/** A workspace's settings, as the sidebar shows them in place of everything else. */
269function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
270 return (
271 <nav
272 aria-label="Workspace settings"
273 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide274 className={PANEL}
A catalogue of model providers, and settings that feel like settings275 >
276 <Link
277 to={`/${slug}`}
278 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
279 >
280 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
281 <span className="truncate">{slug}</span>
282 </Link>
283 <SidebarGroup title="Settings">
284 {owner && (
285 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
286 General
287 </SidebarLink>
288 )}
289 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
290 Members
291 </SidebarLink>
292 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
293 Billing
294 </SidebarLink>
295 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
296 Integrations
297 </SidebarLink>
Webhooks: every event, to your own addresses, signed and retried298 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
299 Webhooks
300 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs301 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
302 Secrets and variables
303 </SidebarLink>
A catalogue of model providers, and settings that feel like settings304 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
305 Access tokens
306 </SidebarLink>
307 </SidebarGroup>
308 </nav>
309 );
310}
311
Sidebar: no settings flash on the way into a repository, and a better slide312/**
313 * What the repository menu needs. Known from the address before the page's
314 * data arrives, so the menu is right from the first frame; the counts fill
315 * in once it does.
316 */
317type MenuRepo = {
318 namespace: string;
319 name: string;
320 member: boolean;
321 issues?: number;
322 pulls?: number;
323};
324
325function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
326 return (
327 a != null &&
328 b != null &&
329 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
330 a.name.toLowerCase() === b.name.toLowerCase()
331 );
332}
A repository has its own sidebar, as settings do333
334/**
335 * A repository's own menu, which the sidebar slides to while you are in
336 * it, as it does for settings: everything about the repository and nothing
337 * else, with the way back to everything.
338 */
Sidebar: no settings flash on the way into a repository, and a better slide339function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
A repository has its own sidebar, as settings do340 const base = `/${repo.namespace}/${repo.name}`;
341 return (
342 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
343 <Link
344 to="/"
345 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
346 >
347 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
348 Mission control
349 </Link>
350 <Link
351 to={base}
352 className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60"
353 >
354 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
355 {isPrivate ? <Lock size={13} /> : <BookMarked size={13} />}
356 </span>
357 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
358 <span className="text-faint">{repo.namespace}/</span>
359 <span className="font-semibold text-fg">{repo.name}</span>
360 </span>
361 </Link>
362 <SidebarGroup title="Code">
363 <SidebarLink to={base} end icon={<Code2 size={15} />} also={`${base}/tree`}>
364 Code
365 </SidebarLink>
366 <SidebarLink to={`${base}/commits`} also={`${base}/commit`} icon={<History size={15} />}>
367 Commits
368 </SidebarLink>
369 </SidebarGroup>
370 <SidebarGroup title="Work">
371 <SidebarLink to={`${base}/issues`} icon={<CircleDot size={15} />} count={repo.issues}>
372 Issues
373 </SidebarLink>
374 <SidebarLink to={`${base}/pulls`} also={`${base}/pull`} icon={<GitPullRequest size={15} />} count={repo.pulls}>
375 Pull requests
376 </SidebarLink>
377 {repo.member && (
378 <SidebarLink to={`${base}/plans`} icon={<ListTree size={15} />}>
379 Plan
380 </SidebarLink>
381 )}
382 <SidebarLink to={`${base}/queue`} icon={<Layers size={15} />}>
383 Merge queue
384 </SidebarLink>
385 </SidebarGroup>
386 <SidebarGroup title="Automate">
387 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
388 Actions
389 </SidebarLink>
390 <SidebarLink to={`${base}/automations`} icon={<Zap size={15} />}>
391 Automations
392 </SidebarLink>
393 </SidebarGroup>
394 {repo.member && (
395 <SidebarGroup title="Repository">
396 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
397 Settings
398 </SidebarLink>
399 </SidebarGroup>
400 )}
401 </nav>
402 );
403}
404
Account settings in the same shape as a workspace's405/** Your own settings, as the sidebar shows them on the settings page. */
406function AccountSettingsMenu({ open }: { open: boolean }) {
407 const { hash } = useLocation();
408 const item = (id: string, icon: ReactNode, label: string) => (
409 <Link
410 to={`/settings#${id}`}
411 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
412 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
413 }`}
414 >
415 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
416 {label}
417 </Link>
418 );
419 return (
420 <nav
421 aria-label="Your settings"
422 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide423 className={PANEL}
Account settings in the same shape as a workspace's424 >
425 <Link
426 to="/"
427 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"
428 >
429 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
430 Mission control
431 </Link>
432 <SidebarGroup title="Account">
433 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
434 {item("tokens", <Lock size={15} />, "Access tokens")}
435 {item("applications", <Plug size={15} />, "Connected applications")}
436 </SidebarGroup>
437 </nav>
438 );
439}
440
A Vercel-like sidebar and a new mark441function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell442 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings443 const { pathname } = useLocation();
444 const going = useNavigation().location?.pathname;
445 // On a workspace's settings page, or on the way to one, its settings take
446 // the sidebar over.
447 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's448 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do449 const active = shell.repo;
450 // In a repository, or on the way into one, its own menu takes the sidebar.
451 const target = going ?? pathname;
452 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
453 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
454 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
455 const away = inSettings || inAccount || inRepo;
Sidebar: no settings flash on the way into a repository, and a better slide456 // The repository the menu is for: the one loaded if it is the one being
457 // gone to, else what the address says, at once.
458 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
459 const menuRepo: MenuRepo | null = targetRepo
460 ? sameRepo(active, targetRepo)
461 ? active
462 : {
463 ...targetRepo,
464 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
465 }
466 : null;
A repository has its own sidebar, as settings do467 // What sits on the far side of the track. Kept while sliding back, so it
468 // does not vanish on the way out.
469 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide470 if (inAccount) side.current = "account";
471 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do472 else if (inRepo) side.current = "repo";
473 // The repository last shown, kept for the slide back.
Sidebar: no settings flash on the way into a repository, and a better slide474 const shown = useRef<MenuRepo | null>(menuRepo);
475 if (menuRepo) shown.current = menuRepo;
476 // Moving between two of the far-side menus (settings to a repository,
477 // one repository to another) crossfades in place instead of sliding.
478 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
479 const lastAway = useRef(away);
480 const lastKey = useRef(detailKey);
481 const swapped = lastAway.current && away && lastKey.current !== detailKey;
482 useEffect(() => {
483 lastAway.current = away;
484 lastKey.current = detailKey;
485 });
Agents as a team: lifecycle, merge queue, billing and a new shell486 // The repository being looked at is listed even when it is someone else's.
487 const listed =
488 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
489 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
490 : shell.repos;
491 return (
492 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark493 {/* The same height and rule as the top bar, so the two read as one line. */}
494 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
495 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
496 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell497 </Link>
A Vercel-like sidebar and a new mark498 <span className="shrink-0 text-line-strong" aria-hidden="true">
499 /
500 </span>
501 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell502 </div>
A Vercel-like sidebar and a new mark503 <div className="px-2 pt-3">
504 <button
505 type="button"
506 onClick={onFind}
507 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"
508 >
509 <Search size={14} />
510 <span className="grow text-left">Find…</span>
511 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
512 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell513 </div>
Sidebar: no settings flash on the way into a repository, and a better slide514 <div className="relative min-h-0 grow overflow-hidden">
515 <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 glide516 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell517 <div className="mt-3 space-y-px">
518 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
519 Mission control
520 </SidebarLink>
521 <SidebarLink to="/explore" icon={<Compass size={15} />}>
522 Explore
523 </SidebarLink>
524 </div>
525
526 <SidebarGroup
527 title="Repositories"
528 action={
529 ws && (
530 <Link
531 to={`/new?workspace=${ws.slug}`}
532 aria-label="New repository"
533 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
534 >
535 <Plus size={13} />
536 </Link>
537 )
538 }
539 >
540 {listed.length === 0 && (
541 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
542 )}
543 {listed.map((repo) => {
544 const base = `/${repo.namespace}/${repo.name}`;
545 return (
546 <div key={base}>
547 <SidebarLink
548 to={base}
549 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
550 >
551 <span className="font-mono text-[0.8125rem]">
552 {repo.namespace !== ws?.slug && (
553 <span className="text-faint">{repo.namespace}/</span>
554 )}
555 {repo.name}
556 </span>
557 </SidebarLink>
558 </div>
559 );
560 })}
561 </SidebarGroup>
562 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide563 </div>
Sidebar: no settings flash on the way into a repository, and a better slide564 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
565 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
566 {side.current === "repo" && shown.current ? (
567 <RepoMenu
568 repo={shown.current}
569 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
570 open={inRepo}
571 />
572 ) : side.current === "account" || !ws ? (
573 <AccountSettingsMenu open={inAccount} />
574 ) : (
575 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
576 )}
577 </div>
578 </div>
A catalogue of model providers, and settings that feel like settings579 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell580
Workspace things at the bottom of the sidebar, and on mission control581 {/* The workspace's own things sit at the bottom, by its usage and the account. */}
A Vercel-like sidebar and a new mark582 <div className="space-y-2 p-2">
Workspace things at the bottom of the sidebar, and on mission control583 {ws && (
584 <div className="space-y-px">
A repository has its own sidebar, as settings do585 <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 control586 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
587 Usage
588 </SidebarLink>
589 {/* Everything else about the workspace lives in its settings, and only there. */}
590 <SidebarLink
591 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
592 icon={<Settings size={15} />}
593 >
594 Settings
595 </SidebarLink>
596 </div>
597 )}
A Vercel-like sidebar and a new mark598 {ws && <UsageCard slug={ws.slug} shell={shell} />}
599 <AccountMenu user={user} />
600 </div>
601 </div>
602 );
603}
604
605/** Words for the sections a path can end in. */
606const SECTIONS: Record<string, string> = {
607 issues: "Issues",
608 pulls: "Pull requests",
609 queue: "Merge queue",
610 commits: "Commits",
611 plans: "Plan",
Automations: rules in .g1t/automations that act when something happens612 automations: "Automations",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs613 actions: "Actions",
614 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark615 settings: "Settings",
A catalogue of model providers, and settings that feel like settings616 people: "Members",
A Vercel-like sidebar and a new mark617 tokens: "Access tokens",
618 usage: "Usage",
619 billing: "Billing",
A catalogue of model providers, and settings that feel like settings620 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried621 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark622 tree: "Code",
623 blob: "Code",
624};
625
626/** Where the page is, as a trail of links: workspace / repository / section. */
627function Breadcrumbs({ pathname }: { pathname: string }) {
628 const parts = pathname.split("/").filter(Boolean);
629 const reserved = ["settings", "explore", "new", "search", "workspaces"];
630 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
631 if (reserved.includes(parts[0]!)) {
632 const words: Record<string, string> = {
Account settings in the same shape as a workspace's633 settings: "Account",
A Vercel-like sidebar and a new mark634 explore: "Explore",
635 new: "New repository",
636 search: "Search",
637 workspaces: "New workspace",
638 };
639 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
640 }
641 const [owner, second, third, fourth] = parts;
642 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
643 if (second === "-") {
A catalogue of model providers, and settings that feel like settings644 const page = `/${owner}/-/${third}`;
645 if (third && SETTINGS_PAGE.test(page)) {
646 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
647 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
648 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark649 } else if (second) {
650 const repo = `/${owner}/${second}`;
651 trail.push({ label: second, to: repo, mono: true });
652 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
653 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
654 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
655 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
656 }
657 return (
658 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
659 {trail.map((crumb, index) => (
660 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
661 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell662 <Link
A Vercel-like sidebar and a new mark663 to={crumb.to}
664 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
665 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
666 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell667 >
A Vercel-like sidebar and a new mark668 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell669 </Link>
A Vercel-like sidebar and a new mark670 </span>
671 ))}
672 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell673 );
674}
675
676type Command = { label: string; hint?: string; to: string; icon: ReactNode };
677
678/** Everything the palette can jump to, from what the sidebar already knows. */
679function commandsFor(user: User, shell: ShellData): Command[] {
680 const commands: Command[] = [
681 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
682 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
683 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
684 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
685 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
686 ];
687 const repo = shell.repo;
688 if (repo) {
689 const base = `/${repo.namespace}/${repo.name}`;
690 const name = `${repo.namespace}/${repo.name}`;
691 commands.unshift(
692 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
693 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
694 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
695 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
696 ...(repo.member
697 ? [
698 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
699 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
700 ]
701 : []),
702 );
703 }
704 for (const membership of user.workspaces ?? []) {
705 commands.push(
706 { 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 request707 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings708 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
709 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
710 { 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 shell711 );
712 }
713 for (const listed of shell.repos) {
714 commands.push({
715 label: `${listed.namespace}/${listed.name}`,
716 hint: "Repository",
717 to: `/${listed.namespace}/${listed.name}`,
718 icon: <BookMarked size={15} />,
719 });
720 }
721 return commands;
722}
723
724function CommandPalette({
725 open,
726 onClose,
727 commands,
728}: {
729 open: boolean;
730 onClose: () => void;
731 commands: Command[];
732}) {
733 const navigate = useNavigate();
734 const [query, setQuery] = useState("");
735 const [selected, setSelected] = useState(0);
736 const input = useRef<HTMLInputElement>(null);
737 const matches = useMemo(() => {
738 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
739 return commands
740 .filter((command) => {
741 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
742 return words.every((word) => text.includes(word));
743 })
744 .slice(0, 12);
745 }, [commands, query]);
746
747 useEffect(() => {
748 if (!open) return;
749 setQuery("");
750 setSelected(0);
751 input.current?.focus();
752 }, [open]);
753 useEffect(() => setSelected(0), [query]);
754
755 if (!open) return null;
756 const go = (command: Command | undefined) => {
757 if (!command) return;
758 onClose();
759 navigate(command.to);
760 };
761 return (
762 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
763 <div
764 role="dialog"
765 aria-label="Go to"
766 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
767 onClick={(event) => event.stopPropagation()}
768 >
769 <div className="flex items-center gap-3 border-b border-line px-4">
770 <Search size={16} className="text-faint" />
771 <input
772 ref={input}
773 value={query}
774 onChange={(event) => setQuery(event.target.value)}
775 onKeyDown={(event) => {
776 if (event.key === "ArrowDown") {
777 event.preventDefault();
778 setSelected((index) => Math.min(index + 1, matches.length - 1));
779 } else if (event.key === "ArrowUp") {
780 event.preventDefault();
781 setSelected((index) => Math.max(index - 1, 0));
782 } else if (event.key === "Enter") {
783 event.preventDefault();
784 go(matches[selected]);
785 } else if (event.key === "Escape") {
786 onClose();
787 }
788 }}
789 placeholder="Go to a repository, a page, or an action…"
790 autoComplete="off"
791 data-1p-ignore
792 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"
793 />
794 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
795 </div>
796 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
797 {matches.length === 0 && (
798 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
799 )}
800 {matches.map((command, index) => (
801 <li key={`${command.to}-${command.label}`}>
802 <button
803 type="button"
804 onMouseEnter={() => setSelected(index)}
805 onClick={() => go(command)}
806 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
807 index === selected ? "bg-line text-fg" : "text-muted"
808 }`}
809 >
810 <span className="shrink-0 text-faint">{command.icon}</span>
811 <span className="min-w-0 grow truncate">{command.label}</span>
812 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
813 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
814 </button>
815 </li>
816 ))}
817 </ul>
818 </div>
819 </div>
820 );
821}
822
823/**
824 * A bar across the top of the page while the next one loads, as GitHub
825 * has: it appears at once, creeps towards the end while waiting, then fills
826 * and fades when the page arrives.
827 */
828export function Progress() {
829 const navigation = useNavigation();
830 const busy = navigation.state !== "idle";
831 const [width, setWidth] = useState(0);
832 const [visible, setVisible] = useState(false);
833 useEffect(() => {
834 if (busy) {
835 setVisible(true);
836 setWidth(12);
837 // Slows as it goes, never quite arriving until the page does.
838 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
839 return () => clearInterval(timer);
840 }
841 setWidth((now) => (now > 0 ? 100 : 0));
842 const fade = setTimeout(() => setVisible(false), 250);
843 const reset = setTimeout(() => setWidth(0), 550);
844 return () => {
845 clearTimeout(fade);
846 clearTimeout(reset);
847 };
848 }, [busy]);
849 return (
850 <div
851 aria-hidden="true"
852 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
853 visible ? "opacity-100" : "opacity-0"
854 }`}
855 >
856 <div
857 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
858 style={{ width: `${width}%` }}
859 />
860 </div>
861 );
862}
863
864/**
865 * The signed-in app: a sidebar with the workspace, its repositories and the
866 * sections of the one being looked at; a slim bar with search and the
867 * account; and the page.
868 */
869export function AppShell({
870 user,
871 shell,
872 banner,
873 children,
874}: {
875 user: User;
876 shell: ShellData;
877 banner?: ReactNode;
878 children: ReactNode;
879}) {
880 const submit = useSubmit();
881 const { pathname } = useLocation();
882 const [drawer, setDrawer] = useState(false);
883 const [palette, setPalette] = useState(false);
884 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
885
886 // A new page closes the drawer on small screens.
887 useEffect(() => setDrawer(false), [pathname]);
888 useEffect(() => {
889 const onKey = (event: KeyboardEvent) => {
890 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
891 event.preventDefault();
892 setPalette((open) => !open);
893 }
894 };
895 window.addEventListener("keydown", onKey);
896 return () => window.removeEventListener("keydown", onKey);
897 }, []);
898
899 return (
900 <div className="min-h-screen">
901 <Progress />
902 <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 mark903 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell904 </aside>
905 {drawer && (
906 <div className="fixed inset-0 z-50 lg:hidden">
907 <button
908 type="button"
909 aria-label="Close menu"
910 className="absolute inset-0 bg-black/60"
911 onClick={() => setDrawer(false)}
912 />
913 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
914 <button
915 type="button"
916 aria-label="Close menu"
917 onClick={() => setDrawer(false)}
918 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
919 >
920 <X size={16} />
921 </button>
A Vercel-like sidebar and a new mark922 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell923 </aside>
924 </div>
925 )}
926
Polish: phones, copy boxes, the plan page, the landing page, a real glide927 <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 shell928 <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">
929 <button
930 type="button"
931 aria-label="Open menu"
932 onClick={() => setDrawer(true)}
933 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
934 >
935 <Menu size={18} />
936 </button>
A Vercel-like sidebar and a new mark937 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell938 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark939 <a
940 href="https://docs.g1t.sh/"
941 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
942 >
943 Docs
944 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell945 <DropdownMenu>
946 <DropdownMenuTrigger
947 aria-label="Create"
A Vercel-like sidebar and a new mark948 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 shell949 >
A Vercel-like sidebar and a new mark950 <Plus size={14} />
951 New
Agents as a team: lifecycle, merge queue, billing and a new shell952 </DropdownMenuTrigger>
953 <DropdownMenuContent align="end">
954 {shell.repo && (
955 <DropdownMenuItem asChild>
956 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
957 <CircleDot />
958 New issue
959 </Link>
960 </DropdownMenuItem>
961 )}
962 {shell.repo?.member && (
963 <DropdownMenuItem asChild>
964 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
965 <ListTree />
966 Plan work
967 </Link>
968 </DropdownMenuItem>
969 )}
970 <DropdownMenuItem asChild>
971 <Link to="/new">
972 <BookMarked />
973 New repository
974 </Link>
975 </DropdownMenuItem>
976 <DropdownMenuItem asChild>
977 <Link to="/workspaces/new">
978 <Users />
979 New workspace
980 </Link>
981 </DropdownMenuItem>
982 </DropdownMenuContent>
983 </DropdownMenu>
984 </div>
985 </header>
986 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide987 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell988 </div>
989 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
990 </div>
991 );
992}