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

901 lines34,596 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
Account settings in the same shape as a workspace's303/** Your own settings, as the sidebar shows them on the settings page. */
304function AccountSettingsMenu({ open }: { open: boolean }) {
305 const { hash } = useLocation();
306 const item = (id: string, icon: ReactNode, label: string) => (
307 <Link
308 to={`/settings#${id}`}
309 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
310 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
311 }`}
312 >
313 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
314 {label}
315 </Link>
316 );
317 return (
318 <nav
319 aria-label="Your settings"
320 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide321 className={PANEL}
Account settings in the same shape as a workspace's322 >
323 <Link
324 to="/"
325 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"
326 >
327 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
328 Mission control
329 </Link>
330 <SidebarGroup title="Account">
331 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
332 {item("tokens", <Lock size={15} />, "Access tokens")}
333 {item("applications", <Plug size={15} />, "Connected applications")}
334 </SidebarGroup>
335 </nav>
336 );
337}
338
A Vercel-like sidebar and a new mark339function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell340 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings341 const { pathname } = useLocation();
342 const going = useNavigation().location?.pathname;
343 // On a workspace's settings page, or on the way to one, its settings take
344 // the sidebar over.
345 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's346 const inAccount = (going ?? pathname) === "/settings";
347 const away = inSettings || inAccount;
Polish: phones, copy boxes, the plan page, the landing page, a real glide348 // Which settings sit on the far side of the track. Kept while sliding back,
349 // so they do not vanish on the way out.
350 const side = useRef<"workspace" | "account">("workspace");
351 if (inAccount) side.current = "account";
352 else if (inSettings) side.current = "workspace";
Agents as a team: lifecycle, merge queue, billing and a new shell353 const active = shell.repo;
354 const repoBase = active ? `/${active.namespace}/${active.name}` : null;
355 // The repository being looked at is listed even when it is someone else's.
356 const listed =
357 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
358 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
359 : shell.repos;
360 return (
361 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark362 {/* The same height and rule as the top bar, so the two read as one line. */}
363 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
364 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
365 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell366 </Link>
A Vercel-like sidebar and a new mark367 <span className="shrink-0 text-line-strong" aria-hidden="true">
368 /
369 </span>
370 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell371 </div>
A Vercel-like sidebar and a new mark372 <div className="px-2 pt-3">
373 <button
374 type="button"
375 onClick={onFind}
376 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"
377 >
378 <Search size={14} />
379 <span className="grow text-left">Find…</span>
380 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
381 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell382 </div>
Polish: phones, copy boxes, the plan page, the landing page, a real glide383 <div className="min-h-0 grow overflow-hidden">
384 <div className={`${TRACK} ${away ? "-translate-x-1/2" : "translate-x-0"}`}>
385 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell386 <div className="mt-3 space-y-px">
387 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
388 Mission control
389 </SidebarLink>
390 <SidebarLink to="/explore" icon={<Compass size={15} />}>
391 Explore
392 </SidebarLink>
393 </div>
394
395 <SidebarGroup
396 title="Repositories"
397 action={
398 ws && (
399 <Link
400 to={`/new?workspace=${ws.slug}`}
401 aria-label="New repository"
402 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
403 >
404 <Plus size={13} />
405 </Link>
406 )
407 }
408 >
409 {listed.length === 0 && (
410 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
411 )}
412 {listed.map((repo) => {
413 const open =
414 active && repo.namespace === active.namespace && repo.name === active.name;
415 const base = `/${repo.namespace}/${repo.name}`;
416 return (
417 <div key={base}>
418 <SidebarLink
419 to={base}
420 end={!open}
421 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
422 >
423 <span className="font-mono text-[0.8125rem]">
424 {repo.namespace !== ws?.slug && (
425 <span className="text-faint">{repo.namespace}/</span>
426 )}
427 {repo.name}
428 </span>
429 </SidebarLink>
430 {open && repoBase && (
431 <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2">
432 <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}>
433 Code
434 </SidebarLink>
435 <SidebarLink
436 to={`${repoBase}/issues`}
437 icon={<CircleDot size={14} />}
438 count={active.issues}
439 >
440 Issues
441 </SidebarLink>
442 <SidebarLink
443 to={`${repoBase}/pulls`}
444 also={`${repoBase}/pull`}
445 icon={<GitPullRequest size={14} />}
446 count={active.pulls}
447 >
448 Pull requests
449 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs450 <SidebarLink to={`${repoBase}/actions`} icon={<PlayCircle size={14} />}>
451 Actions
452 </SidebarLink>
Agents as a team: lifecycle, merge queue, billing and a new shell453 <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}>
454 Merge queue
455 </SidebarLink>
456 <SidebarLink
457 to={`${repoBase}/commits`}
458 also={`${repoBase}/commit`}
459 icon={<History size={14} />}
460 >
461 Commits
462 </SidebarLink>
463 {active.member && (
464 <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}>
465 Plan
466 </SidebarLink>
467 )}
Automations: rules in .g1t/automations that act when something happens468 <SidebarLink to={`${repoBase}/automations`} icon={<Zap size={14} />}>
469 Automations
470 </SidebarLink>
Agents as a team: lifecycle, merge queue, billing and a new shell471 {active.member && (
472 <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}>
473 Settings
474 </SidebarLink>
475 )}
476 </div>
477 )}
478 </div>
479 );
480 })}
481 </SidebarGroup>
482 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide483 {side.current === "account" || !ws ? (
484 <AccountSettingsMenu open={inAccount} />
485 ) : (
486 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
487 )}
488 </div>
A catalogue of model providers, and settings that feel like settings489 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell490
Workspace things at the bottom of the sidebar, and on mission control491 {/* The workspace's own things sit at the bottom, by its usage and the account. */}
A Vercel-like sidebar and a new mark492 <div className="space-y-2 p-2">
Workspace things at the bottom of the sidebar, and on mission control493 {ws && (
494 <div className="space-y-px">
495 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
496 Usage
497 </SidebarLink>
498 {/* Everything else about the workspace lives in its settings, and only there. */}
499 <SidebarLink
500 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
501 icon={<Settings size={15} />}
502 >
503 Settings
504 </SidebarLink>
505 </div>
506 )}
A Vercel-like sidebar and a new mark507 {ws && <UsageCard slug={ws.slug} shell={shell} />}
508 <AccountMenu user={user} />
509 </div>
510 </div>
511 );
512}
513
514/** Words for the sections a path can end in. */
515const SECTIONS: Record<string, string> = {
516 issues: "Issues",
517 pulls: "Pull requests",
518 queue: "Merge queue",
519 commits: "Commits",
520 plans: "Plan",
Automations: rules in .g1t/automations that act when something happens521 automations: "Automations",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs522 actions: "Actions",
523 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark524 settings: "Settings",
A catalogue of model providers, and settings that feel like settings525 people: "Members",
A Vercel-like sidebar and a new mark526 tokens: "Access tokens",
527 usage: "Usage",
528 billing: "Billing",
A catalogue of model providers, and settings that feel like settings529 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried530 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark531 tree: "Code",
532 blob: "Code",
533};
534
535/** Where the page is, as a trail of links: workspace / repository / section. */
536function Breadcrumbs({ pathname }: { pathname: string }) {
537 const parts = pathname.split("/").filter(Boolean);
538 const reserved = ["settings", "explore", "new", "search", "workspaces"];
539 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
540 if (reserved.includes(parts[0]!)) {
541 const words: Record<string, string> = {
Account settings in the same shape as a workspace's542 settings: "Account",
A Vercel-like sidebar and a new mark543 explore: "Explore",
544 new: "New repository",
545 search: "Search",
546 workspaces: "New workspace",
547 };
548 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
549 }
550 const [owner, second, third, fourth] = parts;
551 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
552 if (second === "-") {
A catalogue of model providers, and settings that feel like settings553 const page = `/${owner}/-/${third}`;
554 if (third && SETTINGS_PAGE.test(page)) {
555 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
556 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
557 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark558 } else if (second) {
559 const repo = `/${owner}/${second}`;
560 trail.push({ label: second, to: repo, mono: true });
561 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
562 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
563 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
564 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
565 }
566 return (
567 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
568 {trail.map((crumb, index) => (
569 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
570 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell571 <Link
A Vercel-like sidebar and a new mark572 to={crumb.to}
573 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
574 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
575 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell576 >
A Vercel-like sidebar and a new mark577 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell578 </Link>
A Vercel-like sidebar and a new mark579 </span>
580 ))}
581 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell582 );
583}
584
585type Command = { label: string; hint?: string; to: string; icon: ReactNode };
586
587/** Everything the palette can jump to, from what the sidebar already knows. */
588function commandsFor(user: User, shell: ShellData): Command[] {
589 const commands: Command[] = [
590 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
591 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
592 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
593 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
594 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
595 ];
596 const repo = shell.repo;
597 if (repo) {
598 const base = `/${repo.namespace}/${repo.name}`;
599 const name = `${repo.namespace}/${repo.name}`;
600 commands.unshift(
601 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
602 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
603 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
604 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
605 ...(repo.member
606 ? [
607 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
608 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
609 ]
610 : []),
611 );
612 }
613 for (const membership of user.workspaces ?? []) {
614 commands.push(
615 { 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 request616 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings617 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
618 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
619 { 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 shell620 );
621 }
622 for (const listed of shell.repos) {
623 commands.push({
624 label: `${listed.namespace}/${listed.name}`,
625 hint: "Repository",
626 to: `/${listed.namespace}/${listed.name}`,
627 icon: <BookMarked size={15} />,
628 });
629 }
630 return commands;
631}
632
633function CommandPalette({
634 open,
635 onClose,
636 commands,
637}: {
638 open: boolean;
639 onClose: () => void;
640 commands: Command[];
641}) {
642 const navigate = useNavigate();
643 const [query, setQuery] = useState("");
644 const [selected, setSelected] = useState(0);
645 const input = useRef<HTMLInputElement>(null);
646 const matches = useMemo(() => {
647 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
648 return commands
649 .filter((command) => {
650 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
651 return words.every((word) => text.includes(word));
652 })
653 .slice(0, 12);
654 }, [commands, query]);
655
656 useEffect(() => {
657 if (!open) return;
658 setQuery("");
659 setSelected(0);
660 input.current?.focus();
661 }, [open]);
662 useEffect(() => setSelected(0), [query]);
663
664 if (!open) return null;
665 const go = (command: Command | undefined) => {
666 if (!command) return;
667 onClose();
668 navigate(command.to);
669 };
670 return (
671 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
672 <div
673 role="dialog"
674 aria-label="Go to"
675 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
676 onClick={(event) => event.stopPropagation()}
677 >
678 <div className="flex items-center gap-3 border-b border-line px-4">
679 <Search size={16} className="text-faint" />
680 <input
681 ref={input}
682 value={query}
683 onChange={(event) => setQuery(event.target.value)}
684 onKeyDown={(event) => {
685 if (event.key === "ArrowDown") {
686 event.preventDefault();
687 setSelected((index) => Math.min(index + 1, matches.length - 1));
688 } else if (event.key === "ArrowUp") {
689 event.preventDefault();
690 setSelected((index) => Math.max(index - 1, 0));
691 } else if (event.key === "Enter") {
692 event.preventDefault();
693 go(matches[selected]);
694 } else if (event.key === "Escape") {
695 onClose();
696 }
697 }}
698 placeholder="Go to a repository, a page, or an action…"
699 autoComplete="off"
700 data-1p-ignore
701 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"
702 />
703 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
704 </div>
705 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
706 {matches.length === 0 && (
707 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
708 )}
709 {matches.map((command, index) => (
710 <li key={`${command.to}-${command.label}`}>
711 <button
712 type="button"
713 onMouseEnter={() => setSelected(index)}
714 onClick={() => go(command)}
715 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
716 index === selected ? "bg-line text-fg" : "text-muted"
717 }`}
718 >
719 <span className="shrink-0 text-faint">{command.icon}</span>
720 <span className="min-w-0 grow truncate">{command.label}</span>
721 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
722 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
723 </button>
724 </li>
725 ))}
726 </ul>
727 </div>
728 </div>
729 );
730}
731
732/**
733 * A bar across the top of the page while the next one loads, as GitHub
734 * has: it appears at once, creeps towards the end while waiting, then fills
735 * and fades when the page arrives.
736 */
737export function Progress() {
738 const navigation = useNavigation();
739 const busy = navigation.state !== "idle";
740 const [width, setWidth] = useState(0);
741 const [visible, setVisible] = useState(false);
742 useEffect(() => {
743 if (busy) {
744 setVisible(true);
745 setWidth(12);
746 // Slows as it goes, never quite arriving until the page does.
747 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
748 return () => clearInterval(timer);
749 }
750 setWidth((now) => (now > 0 ? 100 : 0));
751 const fade = setTimeout(() => setVisible(false), 250);
752 const reset = setTimeout(() => setWidth(0), 550);
753 return () => {
754 clearTimeout(fade);
755 clearTimeout(reset);
756 };
757 }, [busy]);
758 return (
759 <div
760 aria-hidden="true"
761 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
762 visible ? "opacity-100" : "opacity-0"
763 }`}
764 >
765 <div
766 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
767 style={{ width: `${width}%` }}
768 />
769 </div>
770 );
771}
772
773/**
774 * The signed-in app: a sidebar with the workspace, its repositories and the
775 * sections of the one being looked at; a slim bar with search and the
776 * account; and the page.
777 */
778export function AppShell({
779 user,
780 shell,
781 banner,
782 children,
783}: {
784 user: User;
785 shell: ShellData;
786 banner?: ReactNode;
787 children: ReactNode;
788}) {
789 const submit = useSubmit();
790 const { pathname } = useLocation();
791 const [drawer, setDrawer] = useState(false);
792 const [palette, setPalette] = useState(false);
793 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
794
795 // A new page closes the drawer on small screens.
796 useEffect(() => setDrawer(false), [pathname]);
797 useEffect(() => {
798 const onKey = (event: KeyboardEvent) => {
799 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
800 event.preventDefault();
801 setPalette((open) => !open);
802 }
803 };
804 window.addEventListener("keydown", onKey);
805 return () => window.removeEventListener("keydown", onKey);
806 }, []);
807
808 return (
809 <div className="min-h-screen">
810 <Progress />
811 <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 mark812 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell813 </aside>
814 {drawer && (
815 <div className="fixed inset-0 z-50 lg:hidden">
816 <button
817 type="button"
818 aria-label="Close menu"
819 className="absolute inset-0 bg-black/60"
820 onClick={() => setDrawer(false)}
821 />
822 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
823 <button
824 type="button"
825 aria-label="Close menu"
826 onClick={() => setDrawer(false)}
827 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
828 >
829 <X size={16} />
830 </button>
A Vercel-like sidebar and a new mark831 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell832 </aside>
833 </div>
834 )}
835
Polish: phones, copy boxes, the plan page, the landing page, a real glide836 <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 shell837 <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">
838 <button
839 type="button"
840 aria-label="Open menu"
841 onClick={() => setDrawer(true)}
842 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
843 >
844 <Menu size={18} />
845 </button>
A Vercel-like sidebar and a new mark846 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell847 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark848 <a
849 href="https://docs.g1t.sh/"
850 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
851 >
852 Docs
853 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell854 <DropdownMenu>
855 <DropdownMenuTrigger
856 aria-label="Create"
A Vercel-like sidebar and a new mark857 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 shell858 >
A Vercel-like sidebar and a new mark859 <Plus size={14} />
860 New
Agents as a team: lifecycle, merge queue, billing and a new shell861 </DropdownMenuTrigger>
862 <DropdownMenuContent align="end">
863 {shell.repo && (
864 <DropdownMenuItem asChild>
865 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
866 <CircleDot />
867 New issue
868 </Link>
869 </DropdownMenuItem>
870 )}
871 {shell.repo?.member && (
872 <DropdownMenuItem asChild>
873 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
874 <ListTree />
875 Plan work
876 </Link>
877 </DropdownMenuItem>
878 )}
879 <DropdownMenuItem asChild>
880 <Link to="/new">
881 <BookMarked />
882 New repository
883 </Link>
884 </DropdownMenuItem>
885 <DropdownMenuItem asChild>
886 <Link to="/workspaces/new">
887 <Users />
888 New workspace
889 </Link>
890 </DropdownMenuItem>
891 </DropdownMenuContent>
892 </DropdownMenu>
893 </div>
894 </header>
895 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide896 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell897 </div>
898 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
899 </div>
900 );
901}