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

938 lines36,215 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents as a team: lifecycle, merge queue, billing and a new shell1import {
A catalogue of model providers, and settings that feel like settings2 ArrowLeft,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request3 BarChart3,
Agents as a team: lifecycle, merge queue, billing and a new shell4 BookMarked,
5 BookOpen,
6 Check,
7 ChevronsUpDown,
A Vercel-like sidebar and a new mark8 Ellipsis,
Agents as a team: lifecycle, merge queue, billing and a new shell9 CircleDot,
10 Code2,
11 Compass,
12 CornerDownLeft,
13 CreditCard,
14 GitPullRequest,
15 History,
16 KeyRound,
17 Layers,
18 LayoutDashboard,
19 ListTree,
20 Lock,
21 LogOut,
22 Menu,
23 Plus,
24 Search,
Integrations: your own model provider, alerts that open issues, tickets agents read25 Plug,
Agents as a team: lifecycle, merge queue, billing and a new shell26 Settings,
27 Users,
Webhooks: every event, to your own addresses, signed and retried28 Webhook,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs29 PlayCircle,
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;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request66 /** What its agents have cost since the start of the month. */
67 monthSpentMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell68};
69
70function SidebarLink({
71 to,
72 icon,
73 end,
74 count,
75 also,
76 children,
77}: {
78 to: string;
79 icon: ReactNode;
80 end?: boolean;
81 count?: number;
82 /** Another path prefix under which this link is the current one. */
83 also?: string;
84 children: ReactNode;
85}) {
86 const { pathname } = useLocation();
87 return (
88 <NavLink
89 to={to}
90 end={end}
91 prefetch="intent"
92 className={({ isActive, isPending }) => {
93 const current = isActive || (also != null && pathname.startsWith(also + "/"));
A Vercel-like sidebar and a new mark94 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 shell95 current
96 ? "bg-raised font-medium text-fg"
97 : isPending
98 ? "bg-raised/60 text-fg"
99 : "text-muted hover:bg-raised/60 hover:text-fg"
100 }`;
101 }}
102 >
103 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
104 <span className="min-w-0 grow truncate">{children}</span>
105 {count != null && count > 0 && (
106 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
107 {count}
108 </span>
109 )}
110 </NavLink>
111 );
112}
113
114function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
115 return (
A Vercel-like sidebar and a new mark116 <section className="mt-6">
117 <div className="mb-1 flex h-6 items-center justify-between px-2">
118 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell119 {action}
120 </div>
121 <div className="space-y-px">{children}</div>
122 </section>
123 );
124}
125
126function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
127 const current = shell.workspace?.slug;
128 return (
129 <DropdownMenu>
A Vercel-like sidebar and a new mark130 {/* The name goes to the workspace; only the arrows switch it. */}
131 <Link
132 to={current ? `/${current}` : "/workspaces/new"}
133 className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
134 >
Agents as a team: lifecycle, merge queue, billing and a new shell135 {current ? (
A Vercel-like sidebar and a new mark136 <Avatar name={current} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell137 ) : (
A Vercel-like sidebar and a new mark138 <span className="size-5 rounded-md border border-dashed border-line-strong" />
Agents as a team: lifecycle, merge queue, billing and a new shell139 )}
A Vercel-like sidebar and a new mark140 <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
141 {shell.workspace && (
142 <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">
143 {shell.workspace.role}
Agents as a team: lifecycle, merge queue, billing and a new shell144 </span>
A Vercel-like sidebar and a new mark145 )}
146 </Link>
147 <DropdownMenuTrigger
148 aria-label="Switch workspace"
149 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"
150 >
151 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell152 </DropdownMenuTrigger>
153 <DropdownMenuContent align="start" className="w-60">
154 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
155 {(user.workspaces ?? []).map((membership) => (
156 <DropdownMenuItem asChild key={membership.slug}>
157 <Link to={`/${membership.slug}`}>
158 <Avatar name={membership.slug} size={16} square />
159 <span className="grow">{membership.slug}</span>
160 {membership.slug === current && <Check className="text-accent" />}
161 </Link>
162 </DropdownMenuItem>
163 ))}
164 <DropdownMenuSeparator />
165 <DropdownMenuItem asChild>
166 <Link to="/workspaces/new">
167 <Plus />
168 New workspace
169 </Link>
170 </DropdownMenuItem>
171 </DropdownMenuContent>
172 </DropdownMenu>
173 );
174}
175
A Vercel-like sidebar and a new mark176/** This month's spend against what is left, as Vercel shows a plan's usage. */
177function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
178 if (shell.monthSpentMicros == null) return null;
179 const spent = shell.monthSpentMicros;
180 const left = shell.creditMicros;
181 const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0;
182 return (
183 <Link
184 to={`/${slug}/-/usage`}
185 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
186 >
187 <span className="flex items-baseline justify-between text-xs">
188 <span className="font-medium text-fg">Usage</span>
189 <span className="text-faint">this month</span>
190 </span>
191 <span className="mt-2 flex items-baseline justify-between">
192 <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span>
193 {left != null && (
194 <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}>
195 ${(left / MICROS_PER_DOLLAR).toFixed(2)} left
196 </span>
197 )}
198 </span>
199 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
200 <span
201 className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`}
202 style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }}
203 />
204 </span>
205 </Link>
206 );
207}
208
209function AccountMenu({ user }: { user: User }) {
210 const submit = useSubmit();
211 return (
212 <DropdownMenu>
213 <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">
214 <Avatar name={user.username} size={22} />
215 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
216 <Ellipsis size={15} className="shrink-0 text-faint" />
217 </DropdownMenuTrigger>
218 <DropdownMenuContent align="start" side="top" className="w-56">
219 <DropdownMenuLabel>
220 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
221 </DropdownMenuLabel>
222 <DropdownMenuSeparator />
223 <DropdownMenuItem asChild>
224 <Link to="/settings">
225 <Settings />
226 Your settings
227 </Link>
228 </DropdownMenuItem>
229 <DropdownMenuItem asChild>
230 <a href="https://docs.g1t.sh/">
231 <BookOpen />
232 Documentation
233 </a>
234 </DropdownMenuItem>
235 <DropdownMenuSeparator />
236 {/* Submitted from here: the menu closes on select, and a button
237 that has left the page cannot submit a form. */}
238 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
239 <LogOut />
240 Sign out
241 </DropdownMenuItem>
242 </DropdownMenuContent>
243 </DropdownMenu>
244 );
245}
246
A catalogue of model providers, and settings that feel like settings247/** A workspace's settings pages, which the sidebar slides over to. */
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs248const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
A catalogue of model providers, and settings that feel like settings249
Polish: phones, copy boxes, the plan page, the landing page, a real glide250/**
251 * The sidebar's menus sit side by side on one track, and the track slides:
252 * the main menu leaves to the left as the settings arrive from the right,
253 * together, with a long ease-out, the way a phone pushes a screen.
254 */
A quicker settings slide: the same curve over 380ms255const 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 glide256/** One menu on the track. */
257const 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 settings258
259/** A workspace's settings, as the sidebar shows them in place of everything else. */
260function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
261 return (
262 <nav
263 aria-label="Workspace settings"
264 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide265 className={PANEL}
A catalogue of model providers, and settings that feel like settings266 >
267 <Link
268 to={`/${slug}`}
269 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"
270 >
271 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
272 <span className="truncate">{slug}</span>
273 </Link>
274 <SidebarGroup title="Settings">
275 {owner && (
276 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
277 General
278 </SidebarLink>
279 )}
280 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
281 Members
282 </SidebarLink>
283 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
284 Billing
285 </SidebarLink>
286 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
287 Integrations
288 </SidebarLink>
Webhooks: every event, to your own addresses, signed and retried289 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
290 Webhooks
291 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs292 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
293 Secrets and variables
294 </SidebarLink>
A catalogue of model providers, and settings that feel like settings295 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
296 Access tokens
297 </SidebarLink>
298 </SidebarGroup>
299 </nav>
300 );
301}
302
A repository has its own sidebar, as settings do303type ActiveRepo = NonNullable<ShellData["repo"]>;
304
305/**
306 * A repository's own menu, which the sidebar slides to while you are in
307 * it, as it does for settings: everything about the repository and nothing
308 * else, with the way back to everything.
309 */
310function RepoMenu({ repo, isPrivate, open }: { repo: ActiveRepo; isPrivate: boolean; open: boolean }) {
311 const base = `/${repo.namespace}/${repo.name}`;
312 return (
313 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
314 <Link
315 to="/"
316 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"
317 >
318 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
319 Mission control
320 </Link>
321 <Link
322 to={base}
323 className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60"
324 >
325 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
326 {isPrivate ? <Lock size={13} /> : <BookMarked size={13} />}
327 </span>
328 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
329 <span className="text-faint">{repo.namespace}/</span>
330 <span className="font-semibold text-fg">{repo.name}</span>
331 </span>
332 </Link>
333 <SidebarGroup title="Code">
334 <SidebarLink to={base} end icon={<Code2 size={15} />} also={`${base}/tree`}>
335 Code
336 </SidebarLink>
337 <SidebarLink to={`${base}/commits`} also={`${base}/commit`} icon={<History size={15} />}>
338 Commits
339 </SidebarLink>
340 </SidebarGroup>
341 <SidebarGroup title="Work">
342 <SidebarLink to={`${base}/issues`} icon={<CircleDot size={15} />} count={repo.issues}>
343 Issues
344 </SidebarLink>
345 <SidebarLink to={`${base}/pulls`} also={`${base}/pull`} icon={<GitPullRequest size={15} />} count={repo.pulls}>
346 Pull requests
347 </SidebarLink>
348 {repo.member && (
349 <SidebarLink to={`${base}/plans`} icon={<ListTree size={15} />}>
350 Plan
351 </SidebarLink>
352 )}
353 <SidebarLink to={`${base}/queue`} icon={<Layers size={15} />}>
354 Merge queue
355 </SidebarLink>
356 </SidebarGroup>
357 <SidebarGroup title="Automate">
358 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
359 Actions
360 </SidebarLink>
361 <SidebarLink to={`${base}/automations`} icon={<Zap size={15} />}>
362 Automations
363 </SidebarLink>
364 </SidebarGroup>
365 {repo.member && (
366 <SidebarGroup title="Repository">
367 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
368 Settings
369 </SidebarLink>
370 </SidebarGroup>
371 )}
372 </nav>
373 );
374}
375
Account settings in the same shape as a workspace's376/** Your own settings, as the sidebar shows them on the settings page. */
377function AccountSettingsMenu({ open }: { open: boolean }) {
378 const { hash } = useLocation();
379 const item = (id: string, icon: ReactNode, label: string) => (
380 <Link
381 to={`/settings#${id}`}
382 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
383 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
384 }`}
385 >
386 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
387 {label}
388 </Link>
389 );
390 return (
391 <nav
392 aria-label="Your settings"
393 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide394 className={PANEL}
Account settings in the same shape as a workspace's395 >
396 <Link
397 to="/"
398 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"
399 >
400 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
401 Mission control
402 </Link>
403 <SidebarGroup title="Account">
404 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
405 {item("tokens", <Lock size={15} />, "Access tokens")}
406 {item("applications", <Plug size={15} />, "Connected applications")}
407 </SidebarGroup>
408 </nav>
409 );
410}
411
A Vercel-like sidebar and a new mark412function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell413 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings414 const { pathname } = useLocation();
415 const going = useNavigation().location?.pathname;
416 // On a workspace's settings page, or on the way to one, its settings take
417 // the sidebar over.
418 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's419 const inAccount = (going ?? pathname) === "/settings";
A repository has its own sidebar, as settings do420 const active = shell.repo;
421 // In a repository, or on the way into one, its own menu takes the sidebar.
422 const target = going ?? pathname;
423 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
424 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
425 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
426 const away = inSettings || inAccount || inRepo;
427 // What sits on the far side of the track. Kept while sliding back, so it
428 // does not vanish on the way out.
429 const side = useRef<"workspace" | "account" | "repo">("workspace");
Polish: phones, copy boxes, the plan page, the landing page, a real glide430 if (inAccount) side.current = "account";
431 else if (inSettings) side.current = "workspace";
A repository has its own sidebar, as settings do432 else if (inRepo) side.current = "repo";
433 // The repository last shown, kept for the slide back.
434 const shown = useRef(active);
435 if (active) shown.current = active;
Agents as a team: lifecycle, merge queue, billing and a new shell436 // The repository being looked at is listed even when it is someone else's.
437 const listed =
438 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
439 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
440 : shell.repos;
441 return (
442 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark443 {/* The same height and rule as the top bar, so the two read as one line. */}
444 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
445 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
446 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell447 </Link>
A Vercel-like sidebar and a new mark448 <span className="shrink-0 text-line-strong" aria-hidden="true">
449 /
450 </span>
451 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell452 </div>
A Vercel-like sidebar and a new mark453 <div className="px-2 pt-3">
454 <button
455 type="button"
456 onClick={onFind}
457 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"
458 >
459 <Search size={14} />
460 <span className="grow text-left">Find…</span>
461 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
462 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell463 </div>
Polish: phones, copy boxes, the plan page, the landing page, a real glide464 <div className="min-h-0 grow overflow-hidden">
465 <div className={`${TRACK} ${away ? "-translate-x-1/2" : "translate-x-0"}`}>
466 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell467 <div className="mt-3 space-y-px">
468 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
469 Mission control
470 </SidebarLink>
471 <SidebarLink to="/explore" icon={<Compass size={15} />}>
472 Explore
473 </SidebarLink>
474 </div>
475
476 <SidebarGroup
477 title="Repositories"
478 action={
479 ws && (
480 <Link
481 to={`/new?workspace=${ws.slug}`}
482 aria-label="New repository"
483 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
484 >
485 <Plus size={13} />
486 </Link>
487 )
488 }
489 >
490 {listed.length === 0 && (
491 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
492 )}
493 {listed.map((repo) => {
494 const base = `/${repo.namespace}/${repo.name}`;
495 return (
496 <div key={base}>
497 <SidebarLink
498 to={base}
499 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
500 >
501 <span className="font-mono text-[0.8125rem]">
502 {repo.namespace !== ws?.slug && (
503 <span className="text-faint">{repo.namespace}/</span>
504 )}
505 {repo.name}
506 </span>
507 </SidebarLink>
508 </div>
509 );
510 })}
511 </SidebarGroup>
512 </nav>
A repository has its own sidebar, as settings do513 {side.current === "repo" && shown.current ? (
514 <RepoMenu
515 repo={shown.current}
516 isPrivate={shell.repos.some((repo) => repo.namespace === shown.current!.namespace && repo.name === shown.current!.name && repo.isPrivate)}
517 open={inRepo}
518 />
519 ) : side.current === "account" || !ws ? (
Polish: phones, copy boxes, the plan page, the landing page, a real glide520 <AccountSettingsMenu open={inAccount} />
521 ) : (
522 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
523 )}
524 </div>
A catalogue of model providers, and settings that feel like settings525 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell526
Workspace things at the bottom of the sidebar, and on mission control527 {/* The workspace's own things sit at the bottom, by its usage and the account. */}
A Vercel-like sidebar and a new mark528 <div className="space-y-2 p-2">
Workspace things at the bottom of the sidebar, and on mission control529 {ws && (
530 <div className="space-y-px">
A repository has its own sidebar, as settings do531 <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 control532 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
533 Usage
534 </SidebarLink>
535 {/* Everything else about the workspace lives in its settings, and only there. */}
536 <SidebarLink
537 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
538 icon={<Settings size={15} />}
539 >
540 Settings
541 </SidebarLink>
542 </div>
543 )}
A Vercel-like sidebar and a new mark544 {ws && <UsageCard slug={ws.slug} shell={shell} />}
545 <AccountMenu user={user} />
546 </div>
547 </div>
548 );
549}
550
551/** Words for the sections a path can end in. */
552const SECTIONS: Record<string, string> = {
553 issues: "Issues",
554 pulls: "Pull requests",
555 queue: "Merge queue",
556 commits: "Commits",
557 plans: "Plan",
Automations: rules in .g1t/automations that act when something happens558 automations: "Automations",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs559 actions: "Actions",
560 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark561 settings: "Settings",
A catalogue of model providers, and settings that feel like settings562 people: "Members",
A Vercel-like sidebar and a new mark563 tokens: "Access tokens",
564 usage: "Usage",
565 billing: "Billing",
A catalogue of model providers, and settings that feel like settings566 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried567 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark568 tree: "Code",
569 blob: "Code",
570};
571
572/** Where the page is, as a trail of links: workspace / repository / section. */
573function Breadcrumbs({ pathname }: { pathname: string }) {
574 const parts = pathname.split("/").filter(Boolean);
575 const reserved = ["settings", "explore", "new", "search", "workspaces"];
576 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
577 if (reserved.includes(parts[0]!)) {
578 const words: Record<string, string> = {
Account settings in the same shape as a workspace's579 settings: "Account",
A Vercel-like sidebar and a new mark580 explore: "Explore",
581 new: "New repository",
582 search: "Search",
583 workspaces: "New workspace",
584 };
585 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
586 }
587 const [owner, second, third, fourth] = parts;
588 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
589 if (second === "-") {
A catalogue of model providers, and settings that feel like settings590 const page = `/${owner}/-/${third}`;
591 if (third && SETTINGS_PAGE.test(page)) {
592 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
593 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
594 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark595 } else if (second) {
596 const repo = `/${owner}/${second}`;
597 trail.push({ label: second, to: repo, mono: true });
598 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
599 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
600 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
601 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
602 }
603 return (
604 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
605 {trail.map((crumb, index) => (
606 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
607 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell608 <Link
A Vercel-like sidebar and a new mark609 to={crumb.to}
610 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
611 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
612 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell613 >
A Vercel-like sidebar and a new mark614 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell615 </Link>
A Vercel-like sidebar and a new mark616 </span>
617 ))}
618 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell619 );
620}
621
622type Command = { label: string; hint?: string; to: string; icon: ReactNode };
623
624/** Everything the palette can jump to, from what the sidebar already knows. */
625function commandsFor(user: User, shell: ShellData): Command[] {
626 const commands: Command[] = [
627 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
628 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
629 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
630 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
631 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
632 ];
633 const repo = shell.repo;
634 if (repo) {
635 const base = `/${repo.namespace}/${repo.name}`;
636 const name = `${repo.namespace}/${repo.name}`;
637 commands.unshift(
638 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
639 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
640 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
641 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
642 ...(repo.member
643 ? [
644 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
645 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
646 ]
647 : []),
648 );
649 }
650 for (const membership of user.workspaces ?? []) {
651 commands.push(
652 { 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 request653 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings654 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
655 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
656 { 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 shell657 );
658 }
659 for (const listed of shell.repos) {
660 commands.push({
661 label: `${listed.namespace}/${listed.name}`,
662 hint: "Repository",
663 to: `/${listed.namespace}/${listed.name}`,
664 icon: <BookMarked size={15} />,
665 });
666 }
667 return commands;
668}
669
670function CommandPalette({
671 open,
672 onClose,
673 commands,
674}: {
675 open: boolean;
676 onClose: () => void;
677 commands: Command[];
678}) {
679 const navigate = useNavigate();
680 const [query, setQuery] = useState("");
681 const [selected, setSelected] = useState(0);
682 const input = useRef<HTMLInputElement>(null);
683 const matches = useMemo(() => {
684 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
685 return commands
686 .filter((command) => {
687 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
688 return words.every((word) => text.includes(word));
689 })
690 .slice(0, 12);
691 }, [commands, query]);
692
693 useEffect(() => {
694 if (!open) return;
695 setQuery("");
696 setSelected(0);
697 input.current?.focus();
698 }, [open]);
699 useEffect(() => setSelected(0), [query]);
700
701 if (!open) return null;
702 const go = (command: Command | undefined) => {
703 if (!command) return;
704 onClose();
705 navigate(command.to);
706 };
707 return (
708 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
709 <div
710 role="dialog"
711 aria-label="Go to"
712 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
713 onClick={(event) => event.stopPropagation()}
714 >
715 <div className="flex items-center gap-3 border-b border-line px-4">
716 <Search size={16} className="text-faint" />
717 <input
718 ref={input}
719 value={query}
720 onChange={(event) => setQuery(event.target.value)}
721 onKeyDown={(event) => {
722 if (event.key === "ArrowDown") {
723 event.preventDefault();
724 setSelected((index) => Math.min(index + 1, matches.length - 1));
725 } else if (event.key === "ArrowUp") {
726 event.preventDefault();
727 setSelected((index) => Math.max(index - 1, 0));
728 } else if (event.key === "Enter") {
729 event.preventDefault();
730 go(matches[selected]);
731 } else if (event.key === "Escape") {
732 onClose();
733 }
734 }}
735 placeholder="Go to a repository, a page, or an action…"
736 autoComplete="off"
737 data-1p-ignore
738 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"
739 />
740 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
741 </div>
742 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
743 {matches.length === 0 && (
744 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
745 )}
746 {matches.map((command, index) => (
747 <li key={`${command.to}-${command.label}`}>
748 <button
749 type="button"
750 onMouseEnter={() => setSelected(index)}
751 onClick={() => go(command)}
752 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
753 index === selected ? "bg-line text-fg" : "text-muted"
754 }`}
755 >
756 <span className="shrink-0 text-faint">{command.icon}</span>
757 <span className="min-w-0 grow truncate">{command.label}</span>
758 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
759 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
760 </button>
761 </li>
762 ))}
763 </ul>
764 </div>
765 </div>
766 );
767}
768
769/**
770 * A bar across the top of the page while the next one loads, as GitHub
771 * has: it appears at once, creeps towards the end while waiting, then fills
772 * and fades when the page arrives.
773 */
774export function Progress() {
775 const navigation = useNavigation();
776 const busy = navigation.state !== "idle";
777 const [width, setWidth] = useState(0);
778 const [visible, setVisible] = useState(false);
779 useEffect(() => {
780 if (busy) {
781 setVisible(true);
782 setWidth(12);
783 // Slows as it goes, never quite arriving until the page does.
784 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
785 return () => clearInterval(timer);
786 }
787 setWidth((now) => (now > 0 ? 100 : 0));
788 const fade = setTimeout(() => setVisible(false), 250);
789 const reset = setTimeout(() => setWidth(0), 550);
790 return () => {
791 clearTimeout(fade);
792 clearTimeout(reset);
793 };
794 }, [busy]);
795 return (
796 <div
797 aria-hidden="true"
798 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
799 visible ? "opacity-100" : "opacity-0"
800 }`}
801 >
802 <div
803 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
804 style={{ width: `${width}%` }}
805 />
806 </div>
807 );
808}
809
810/**
811 * The signed-in app: a sidebar with the workspace, its repositories and the
812 * sections of the one being looked at; a slim bar with search and the
813 * account; and the page.
814 */
815export function AppShell({
816 user,
817 shell,
818 banner,
819 children,
820}: {
821 user: User;
822 shell: ShellData;
823 banner?: ReactNode;
824 children: ReactNode;
825}) {
826 const submit = useSubmit();
827 const { pathname } = useLocation();
828 const [drawer, setDrawer] = useState(false);
829 const [palette, setPalette] = useState(false);
830 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
831
832 // A new page closes the drawer on small screens.
833 useEffect(() => setDrawer(false), [pathname]);
834 useEffect(() => {
835 const onKey = (event: KeyboardEvent) => {
836 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
837 event.preventDefault();
838 setPalette((open) => !open);
839 }
840 };
841 window.addEventListener("keydown", onKey);
842 return () => window.removeEventListener("keydown", onKey);
843 }, []);
844
845 return (
846 <div className="min-h-screen">
847 <Progress />
848 <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 mark849 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell850 </aside>
851 {drawer && (
852 <div className="fixed inset-0 z-50 lg:hidden">
853 <button
854 type="button"
855 aria-label="Close menu"
856 className="absolute inset-0 bg-black/60"
857 onClick={() => setDrawer(false)}
858 />
859 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
860 <button
861 type="button"
862 aria-label="Close menu"
863 onClick={() => setDrawer(false)}
864 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
865 >
866 <X size={16} />
867 </button>
A Vercel-like sidebar and a new mark868 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell869 </aside>
870 </div>
871 )}
872
Polish: phones, copy boxes, the plan page, the landing page, a real glide873 <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 shell874 <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">
875 <button
876 type="button"
877 aria-label="Open menu"
878 onClick={() => setDrawer(true)}
879 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
880 >
881 <Menu size={18} />
882 </button>
A Vercel-like sidebar and a new mark883 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell884 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark885 <a
886 href="https://docs.g1t.sh/"
887 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
888 >
889 Docs
890 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell891 <DropdownMenu>
892 <DropdownMenuTrigger
893 aria-label="Create"
A Vercel-like sidebar and a new mark894 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 shell895 >
A Vercel-like sidebar and a new mark896 <Plus size={14} />
897 New
Agents as a team: lifecycle, merge queue, billing and a new shell898 </DropdownMenuTrigger>
899 <DropdownMenuContent align="end">
900 {shell.repo && (
901 <DropdownMenuItem asChild>
902 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
903 <CircleDot />
904 New issue
905 </Link>
906 </DropdownMenuItem>
907 )}
908 {shell.repo?.member && (
909 <DropdownMenuItem asChild>
910 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
911 <ListTree />
912 Plan work
913 </Link>
914 </DropdownMenuItem>
915 )}
916 <DropdownMenuItem asChild>
917 <Link to="/new">
918 <BookMarked />
919 New repository
920 </Link>
921 </DropdownMenuItem>
922 <DropdownMenuItem asChild>
923 <Link to="/workspaces/new">
924 <Users />
925 New workspace
926 </Link>
927 </DropdownMenuItem>
928 </DropdownMenuContent>
929 </DropdownMenu>
930 </div>
931 </header>
932 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide933 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell934 </div>
935 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
936 </div>
937 );
938}