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

895 lines34,289 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,
Automations: rules in .g1t/automations that act when something happens29 Zap,
Agents as a team: lifecycle, merge queue, billing and a new shell30 X,
31} from "lucide-react";
32import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
34
35import type { User } from "@g1t/contracts";
36import { MICROS_PER_DOLLAR } from "@g1t/contracts";
37
A Vercel-like sidebar and a new mark38import { Mark } from "./logo";
Agents as a team: lifecycle, merge queue, billing and a new shell39import { Avatar } from "./ui";
40import {
41 DropdownMenu,
42 DropdownMenuContent,
43 DropdownMenuItem,
44 DropdownMenuLabel,
45 DropdownMenuSeparator,
46 DropdownMenuTrigger,
47} from "./ui/dropdown-menu";
48
49/** What the sidebar needs, worked out by the root loader for a signed-in person. */
50export type ShellData = {
51 /** The workspace the sidebar is about: the one being looked at, or their first. */
52 workspace: { slug: string; role: "owner" | "member" } | null;
53 /** Its repositories, newest first. */
54 repos: { namespace: string; name: string; isPrivate: boolean }[];
55 /** The repository being looked at, if any, whoever owns it. */
56 repo: {
57 namespace: string;
58 name: string;
59 member: boolean;
60 issues: number;
61 pulls: number;
62 } | null;
63 /** The workspace's agent credit, if billing is on and they may see it. */
64 creditMicros: number | null;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request65 /** What its agents have cost since the start of the month. */
66 monthSpentMicros: number | null;
Agents as a team: lifecycle, merge queue, billing and a new shell67};
68
69function SidebarLink({
70 to,
71 icon,
72 end,
73 count,
74 also,
75 children,
76}: {
77 to: string;
78 icon: ReactNode;
79 end?: boolean;
80 count?: number;
81 /** Another path prefix under which this link is the current one. */
82 also?: string;
83 children: ReactNode;
84}) {
85 const { pathname } = useLocation();
86 return (
87 <NavLink
88 to={to}
89 end={end}
90 prefetch="intent"
91 className={({ isActive, isPending }) => {
92 const current = isActive || (also != null && pathname.startsWith(also + "/"));
A Vercel-like sidebar and a new mark93 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 shell94 current
95 ? "bg-raised font-medium text-fg"
96 : isPending
97 ? "bg-raised/60 text-fg"
98 : "text-muted hover:bg-raised/60 hover:text-fg"
99 }`;
100 }}
101 >
102 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
103 <span className="min-w-0 grow truncate">{children}</span>
104 {count != null && count > 0 && (
105 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
106 {count}
107 </span>
108 )}
109 </NavLink>
110 );
111}
112
113function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
114 return (
A Vercel-like sidebar and a new mark115 <section className="mt-6">
116 <div className="mb-1 flex h-6 items-center justify-between px-2">
117 <h2 className="text-xs font-medium text-faint">{title}</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell118 {action}
119 </div>
120 <div className="space-y-px">{children}</div>
121 </section>
122 );
123}
124
125function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
126 const current = shell.workspace?.slug;
127 return (
128 <DropdownMenu>
A Vercel-like sidebar and a new mark129 {/* The name goes to the workspace; only the arrows switch it. */}
130 <Link
131 to={current ? `/${current}` : "/workspaces/new"}
132 className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
133 >
Agents as a team: lifecycle, merge queue, billing and a new shell134 {current ? (
A Vercel-like sidebar and a new mark135 <Avatar name={current} size={20} square />
Agents as a team: lifecycle, merge queue, billing and a new shell136 ) : (
A Vercel-like sidebar and a new mark137 <span className="size-5 rounded-md border border-dashed border-line-strong" />
Agents as a team: lifecycle, merge queue, billing and a new shell138 )}
A Vercel-like sidebar and a new mark139 <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
140 {shell.workspace && (
141 <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">
142 {shell.workspace.role}
Agents as a team: lifecycle, merge queue, billing and a new shell143 </span>
A Vercel-like sidebar and a new mark144 )}
145 </Link>
146 <DropdownMenuTrigger
147 aria-label="Switch workspace"
148 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"
149 >
150 <ChevronsUpDown size={14} />
Agents as a team: lifecycle, merge queue, billing and a new shell151 </DropdownMenuTrigger>
152 <DropdownMenuContent align="start" className="w-60">
153 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
154 {(user.workspaces ?? []).map((membership) => (
155 <DropdownMenuItem asChild key={membership.slug}>
156 <Link to={`/${membership.slug}`}>
157 <Avatar name={membership.slug} size={16} square />
158 <span className="grow">{membership.slug}</span>
159 {membership.slug === current && <Check className="text-accent" />}
160 </Link>
161 </DropdownMenuItem>
162 ))}
163 <DropdownMenuSeparator />
164 <DropdownMenuItem asChild>
165 <Link to="/workspaces/new">
166 <Plus />
167 New workspace
168 </Link>
169 </DropdownMenuItem>
170 </DropdownMenuContent>
171 </DropdownMenu>
172 );
173}
174
A Vercel-like sidebar and a new mark175/** This month's spend against what is left, as Vercel shows a plan's usage. */
176function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
177 if (shell.monthSpentMicros == null) return null;
178 const spent = shell.monthSpentMicros;
179 const left = shell.creditMicros;
180 const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0;
181 return (
182 <Link
183 to={`/${slug}/-/usage`}
184 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
185 >
186 <span className="flex items-baseline justify-between text-xs">
187 <span className="font-medium text-fg">Usage</span>
188 <span className="text-faint">this month</span>
189 </span>
190 <span className="mt-2 flex items-baseline justify-between">
191 <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span>
192 {left != null && (
193 <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}>
194 ${(left / MICROS_PER_DOLLAR).toFixed(2)} left
195 </span>
196 )}
197 </span>
198 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
199 <span
200 className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`}
201 style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }}
202 />
203 </span>
204 </Link>
205 );
206}
207
208function AccountMenu({ user }: { user: User }) {
209 const submit = useSubmit();
210 return (
211 <DropdownMenu>
212 <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">
213 <Avatar name={user.username} size={22} />
214 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
215 <Ellipsis size={15} className="shrink-0 text-faint" />
216 </DropdownMenuTrigger>
217 <DropdownMenuContent align="start" side="top" className="w-56">
218 <DropdownMenuLabel>
219 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
220 </DropdownMenuLabel>
221 <DropdownMenuSeparator />
222 <DropdownMenuItem asChild>
223 <Link to="/settings">
224 <Settings />
225 Your settings
226 </Link>
227 </DropdownMenuItem>
228 <DropdownMenuItem asChild>
229 <a href="https://docs.g1t.sh/">
230 <BookOpen />
231 Documentation
232 </a>
233 </DropdownMenuItem>
234 <DropdownMenuSeparator />
235 {/* Submitted from here: the menu closes on select, and a button
236 that has left the page cannot submit a form. */}
237 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
238 <LogOut />
239 Sign out
240 </DropdownMenuItem>
241 </DropdownMenuContent>
242 </DropdownMenu>
243 );
244}
245
A catalogue of model providers, and settings that feel like settings246/** A workspace's settings pages, which the sidebar slides over to. */
Webhooks: every event, to your own addresses, signed and retried247const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks)(\/|$)/;
A catalogue of model providers, and settings that feel like settings248
Polish: phones, copy boxes, the plan page, the landing page, a real glide249/**
250 * The sidebar's menus sit side by side on one track, and the track slides:
251 * the main menu leaves to the left as the settings arrive from the right,
252 * together, with a long ease-out, the way a phone pushes a screen.
253 */
A quicker settings slide: the same curve over 380ms254const 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 glide255/** One menu on the track. */
256const 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 settings257
258/** A workspace's settings, as the sidebar shows them in place of everything else. */
259function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
260 return (
261 <nav
262 aria-label="Workspace settings"
263 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide264 className={PANEL}
A catalogue of model providers, and settings that feel like settings265 >
266 <Link
267 to={`/${slug}`}
268 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"
269 >
270 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
271 <span className="truncate">{slug}</span>
272 </Link>
273 <SidebarGroup title="Settings">
274 {owner && (
275 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
276 General
277 </SidebarLink>
278 )}
279 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
280 Members
281 </SidebarLink>
282 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
283 Billing
284 </SidebarLink>
285 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
286 Integrations
287 </SidebarLink>
Webhooks: every event, to your own addresses, signed and retried288 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
289 Webhooks
290 </SidebarLink>
A catalogue of model providers, and settings that feel like settings291 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
292 Access tokens
293 </SidebarLink>
294 </SidebarGroup>
295 </nav>
296 );
297}
298
Account settings in the same shape as a workspace's299/** Your own settings, as the sidebar shows them on the settings page. */
300function AccountSettingsMenu({ open }: { open: boolean }) {
301 const { hash } = useLocation();
302 const item = (id: string, icon: ReactNode, label: string) => (
303 <Link
304 to={`/settings#${id}`}
305 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
306 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
307 }`}
308 >
309 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
310 {label}
311 </Link>
312 );
313 return (
314 <nav
315 aria-label="Your settings"
316 inert={!open}
Polish: phones, copy boxes, the plan page, the landing page, a real glide317 className={PANEL}
Account settings in the same shape as a workspace's318 >
319 <Link
320 to="/"
321 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"
322 >
323 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
324 Mission control
325 </Link>
326 <SidebarGroup title="Account">
327 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
328 {item("tokens", <Lock size={15} />, "Access tokens")}
329 {item("applications", <Plug size={15} />, "Connected applications")}
330 </SidebarGroup>
331 </nav>
332 );
333}
334
A Vercel-like sidebar and a new mark335function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell336 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings337 const { pathname } = useLocation();
338 const going = useNavigation().location?.pathname;
339 // On a workspace's settings page, or on the way to one, its settings take
340 // the sidebar over.
341 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's342 const inAccount = (going ?? pathname) === "/settings";
343 const away = inSettings || inAccount;
Polish: phones, copy boxes, the plan page, the landing page, a real glide344 // Which settings sit on the far side of the track. Kept while sliding back,
345 // so they do not vanish on the way out.
346 const side = useRef<"workspace" | "account">("workspace");
347 if (inAccount) side.current = "account";
348 else if (inSettings) side.current = "workspace";
Agents as a team: lifecycle, merge queue, billing and a new shell349 const active = shell.repo;
350 const repoBase = active ? `/${active.namespace}/${active.name}` : null;
351 // The repository being looked at is listed even when it is someone else's.
352 const listed =
353 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
354 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
355 : shell.repos;
356 return (
357 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark358 {/* The same height and rule as the top bar, so the two read as one line. */}
359 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
360 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
361 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell362 </Link>
A Vercel-like sidebar and a new mark363 <span className="shrink-0 text-line-strong" aria-hidden="true">
364 /
365 </span>
366 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell367 </div>
A Vercel-like sidebar and a new mark368 <div className="px-2 pt-3">
369 <button
370 type="button"
371 onClick={onFind}
372 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"
373 >
374 <Search size={14} />
375 <span className="grow text-left">Find…</span>
376 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
377 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell378 </div>
Polish: phones, copy boxes, the plan page, the landing page, a real glide379 <div className="min-h-0 grow overflow-hidden">
380 <div className={`${TRACK} ${away ? "-translate-x-1/2" : "translate-x-0"}`}>
381 <nav aria-label="g1t" inert={away} className={PANEL}>
Agents as a team: lifecycle, merge queue, billing and a new shell382 <div className="mt-3 space-y-px">
383 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
384 Mission control
385 </SidebarLink>
386 <SidebarLink to="/explore" icon={<Compass size={15} />}>
387 Explore
388 </SidebarLink>
389 </div>
390
391 {ws && (
392 <SidebarGroup title="Workspace">
393 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}>
394 Overview
395 </SidebarLink>
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request396 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
397 Usage
398 </SidebarLink>
A catalogue of model providers, and settings that feel like settings399 {/* Everything else about the workspace lives in its settings, and only there. */}
400 <SidebarLink
401 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
402 icon={<Settings size={15} />}
403 >
404 Settings
Integrations: your own model provider, alerts that open issues, tickets agents read405 </SidebarLink>
Agents as a team: lifecycle, merge queue, billing and a new shell406 </SidebarGroup>
407 )}
408
409 <SidebarGroup
410 title="Repositories"
411 action={
412 ws && (
413 <Link
414 to={`/new?workspace=${ws.slug}`}
415 aria-label="New repository"
416 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
417 >
418 <Plus size={13} />
419 </Link>
420 )
421 }
422 >
423 {listed.length === 0 && (
424 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
425 )}
426 {listed.map((repo) => {
427 const open =
428 active && repo.namespace === active.namespace && repo.name === active.name;
429 const base = `/${repo.namespace}/${repo.name}`;
430 return (
431 <div key={base}>
432 <SidebarLink
433 to={base}
434 end={!open}
435 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
436 >
437 <span className="font-mono text-[0.8125rem]">
438 {repo.namespace !== ws?.slug && (
439 <span className="text-faint">{repo.namespace}/</span>
440 )}
441 {repo.name}
442 </span>
443 </SidebarLink>
444 {open && repoBase && (
445 <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2">
446 <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}>
447 Code
448 </SidebarLink>
449 <SidebarLink
450 to={`${repoBase}/issues`}
451 icon={<CircleDot size={14} />}
452 count={active.issues}
453 >
454 Issues
455 </SidebarLink>
456 <SidebarLink
457 to={`${repoBase}/pulls`}
458 also={`${repoBase}/pull`}
459 icon={<GitPullRequest size={14} />}
460 count={active.pulls}
461 >
462 Pull requests
463 </SidebarLink>
464 <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}>
465 Merge queue
466 </SidebarLink>
467 <SidebarLink
468 to={`${repoBase}/commits`}
469 also={`${repoBase}/commit`}
470 icon={<History size={14} />}
471 >
472 Commits
473 </SidebarLink>
474 {active.member && (
475 <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}>
476 Plan
477 </SidebarLink>
478 )}
Automations: rules in .g1t/automations that act when something happens479 <SidebarLink to={`${repoBase}/automations`} icon={<Zap size={14} />}>
480 Automations
481 </SidebarLink>
Agents as a team: lifecycle, merge queue, billing and a new shell482 {active.member && (
483 <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}>
484 Settings
485 </SidebarLink>
486 )}
487 </div>
488 )}
489 </div>
490 );
491 })}
492 </SidebarGroup>
493 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide494 {side.current === "account" || !ws ? (
495 <AccountSettingsMenu open={inAccount} />
496 ) : (
497 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
498 )}
499 </div>
A catalogue of model providers, and settings that feel like settings500 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell501
A Vercel-like sidebar and a new mark502 <div className="space-y-2 p-2">
503 {ws && <UsageCard slug={ws.slug} shell={shell} />}
504 <AccountMenu user={user} />
505 </div>
506 </div>
507 );
508}
509
510/** Words for the sections a path can end in. */
511const SECTIONS: Record<string, string> = {
512 issues: "Issues",
513 pulls: "Pull requests",
514 queue: "Merge queue",
515 commits: "Commits",
516 plans: "Plan",
Automations: rules in .g1t/automations that act when something happens517 automations: "Automations",
A Vercel-like sidebar and a new mark518 settings: "Settings",
A catalogue of model providers, and settings that feel like settings519 people: "Members",
A Vercel-like sidebar and a new mark520 tokens: "Access tokens",
521 usage: "Usage",
522 billing: "Billing",
A catalogue of model providers, and settings that feel like settings523 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried524 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark525 tree: "Code",
526 blob: "Code",
527};
528
529/** Where the page is, as a trail of links: workspace / repository / section. */
530function Breadcrumbs({ pathname }: { pathname: string }) {
531 const parts = pathname.split("/").filter(Boolean);
532 const reserved = ["settings", "explore", "new", "search", "workspaces"];
533 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
534 if (reserved.includes(parts[0]!)) {
535 const words: Record<string, string> = {
Account settings in the same shape as a workspace's536 settings: "Account",
A Vercel-like sidebar and a new mark537 explore: "Explore",
538 new: "New repository",
539 search: "Search",
540 workspaces: "New workspace",
541 };
542 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
543 }
544 const [owner, second, third, fourth] = parts;
545 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
546 if (second === "-") {
A catalogue of model providers, and settings that feel like settings547 const page = `/${owner}/-/${third}`;
548 if (third && SETTINGS_PAGE.test(page)) {
549 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
550 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
551 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark552 } else if (second) {
553 const repo = `/${owner}/${second}`;
554 trail.push({ label: second, to: repo, mono: true });
555 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
556 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
557 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
558 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
559 }
560 return (
561 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
562 {trail.map((crumb, index) => (
563 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
564 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell565 <Link
A Vercel-like sidebar and a new mark566 to={crumb.to}
567 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
568 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
569 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell570 >
A Vercel-like sidebar and a new mark571 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell572 </Link>
A Vercel-like sidebar and a new mark573 </span>
574 ))}
575 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell576 );
577}
578
579type Command = { label: string; hint?: string; to: string; icon: ReactNode };
580
581/** Everything the palette can jump to, from what the sidebar already knows. */
582function commandsFor(user: User, shell: ShellData): Command[] {
583 const commands: Command[] = [
584 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
585 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
586 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
587 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
588 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
589 ];
590 const repo = shell.repo;
591 if (repo) {
592 const base = `/${repo.namespace}/${repo.name}`;
593 const name = `${repo.namespace}/${repo.name}`;
594 commands.unshift(
595 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
596 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
597 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
598 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
599 ...(repo.member
600 ? [
601 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
602 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
603 ]
604 : []),
605 );
606 }
607 for (const membership of user.workspaces ?? []) {
608 commands.push(
609 { 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 request610 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings611 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
612 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
613 { 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 shell614 );
615 }
616 for (const listed of shell.repos) {
617 commands.push({
618 label: `${listed.namespace}/${listed.name}`,
619 hint: "Repository",
620 to: `/${listed.namespace}/${listed.name}`,
621 icon: <BookMarked size={15} />,
622 });
623 }
624 return commands;
625}
626
627function CommandPalette({
628 open,
629 onClose,
630 commands,
631}: {
632 open: boolean;
633 onClose: () => void;
634 commands: Command[];
635}) {
636 const navigate = useNavigate();
637 const [query, setQuery] = useState("");
638 const [selected, setSelected] = useState(0);
639 const input = useRef<HTMLInputElement>(null);
640 const matches = useMemo(() => {
641 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
642 return commands
643 .filter((command) => {
644 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
645 return words.every((word) => text.includes(word));
646 })
647 .slice(0, 12);
648 }, [commands, query]);
649
650 useEffect(() => {
651 if (!open) return;
652 setQuery("");
653 setSelected(0);
654 input.current?.focus();
655 }, [open]);
656 useEffect(() => setSelected(0), [query]);
657
658 if (!open) return null;
659 const go = (command: Command | undefined) => {
660 if (!command) return;
661 onClose();
662 navigate(command.to);
663 };
664 return (
665 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
666 <div
667 role="dialog"
668 aria-label="Go to"
669 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
670 onClick={(event) => event.stopPropagation()}
671 >
672 <div className="flex items-center gap-3 border-b border-line px-4">
673 <Search size={16} className="text-faint" />
674 <input
675 ref={input}
676 value={query}
677 onChange={(event) => setQuery(event.target.value)}
678 onKeyDown={(event) => {
679 if (event.key === "ArrowDown") {
680 event.preventDefault();
681 setSelected((index) => Math.min(index + 1, matches.length - 1));
682 } else if (event.key === "ArrowUp") {
683 event.preventDefault();
684 setSelected((index) => Math.max(index - 1, 0));
685 } else if (event.key === "Enter") {
686 event.preventDefault();
687 go(matches[selected]);
688 } else if (event.key === "Escape") {
689 onClose();
690 }
691 }}
692 placeholder="Go to a repository, a page, or an action…"
693 autoComplete="off"
694 data-1p-ignore
695 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"
696 />
697 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
698 </div>
699 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
700 {matches.length === 0 && (
701 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
702 )}
703 {matches.map((command, index) => (
704 <li key={`${command.to}-${command.label}`}>
705 <button
706 type="button"
707 onMouseEnter={() => setSelected(index)}
708 onClick={() => go(command)}
709 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
710 index === selected ? "bg-line text-fg" : "text-muted"
711 }`}
712 >
713 <span className="shrink-0 text-faint">{command.icon}</span>
714 <span className="min-w-0 grow truncate">{command.label}</span>
715 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
716 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
717 </button>
718 </li>
719 ))}
720 </ul>
721 </div>
722 </div>
723 );
724}
725
726/**
727 * A bar across the top of the page while the next one loads, as GitHub
728 * has: it appears at once, creeps towards the end while waiting, then fills
729 * and fades when the page arrives.
730 */
731export function Progress() {
732 const navigation = useNavigation();
733 const busy = navigation.state !== "idle";
734 const [width, setWidth] = useState(0);
735 const [visible, setVisible] = useState(false);
736 useEffect(() => {
737 if (busy) {
738 setVisible(true);
739 setWidth(12);
740 // Slows as it goes, never quite arriving until the page does.
741 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
742 return () => clearInterval(timer);
743 }
744 setWidth((now) => (now > 0 ? 100 : 0));
745 const fade = setTimeout(() => setVisible(false), 250);
746 const reset = setTimeout(() => setWidth(0), 550);
747 return () => {
748 clearTimeout(fade);
749 clearTimeout(reset);
750 };
751 }, [busy]);
752 return (
753 <div
754 aria-hidden="true"
755 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
756 visible ? "opacity-100" : "opacity-0"
757 }`}
758 >
759 <div
760 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
761 style={{ width: `${width}%` }}
762 />
763 </div>
764 );
765}
766
767/**
768 * The signed-in app: a sidebar with the workspace, its repositories and the
769 * sections of the one being looked at; a slim bar with search and the
770 * account; and the page.
771 */
772export function AppShell({
773 user,
774 shell,
775 banner,
776 children,
777}: {
778 user: User;
779 shell: ShellData;
780 banner?: ReactNode;
781 children: ReactNode;
782}) {
783 const submit = useSubmit();
784 const { pathname } = useLocation();
785 const [drawer, setDrawer] = useState(false);
786 const [palette, setPalette] = useState(false);
787 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
788
789 // A new page closes the drawer on small screens.
790 useEffect(() => setDrawer(false), [pathname]);
791 useEffect(() => {
792 const onKey = (event: KeyboardEvent) => {
793 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
794 event.preventDefault();
795 setPalette((open) => !open);
796 }
797 };
798 window.addEventListener("keydown", onKey);
799 return () => window.removeEventListener("keydown", onKey);
800 }, []);
801
802 return (
803 <div className="min-h-screen">
804 <Progress />
805 <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 mark806 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell807 </aside>
808 {drawer && (
809 <div className="fixed inset-0 z-50 lg:hidden">
810 <button
811 type="button"
812 aria-label="Close menu"
813 className="absolute inset-0 bg-black/60"
814 onClick={() => setDrawer(false)}
815 />
816 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
817 <button
818 type="button"
819 aria-label="Close menu"
820 onClick={() => setDrawer(false)}
821 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
822 >
823 <X size={16} />
824 </button>
A Vercel-like sidebar and a new mark825 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell826 </aside>
827 </div>
828 )}
829
Polish: phones, copy boxes, the plan page, the landing page, a real glide830 <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 shell831 <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">
832 <button
833 type="button"
834 aria-label="Open menu"
835 onClick={() => setDrawer(true)}
836 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
837 >
838 <Menu size={18} />
839 </button>
A Vercel-like sidebar and a new mark840 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell841 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark842 <a
843 href="https://docs.g1t.sh/"
844 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
845 >
846 Docs
847 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell848 <DropdownMenu>
849 <DropdownMenuTrigger
850 aria-label="Create"
A Vercel-like sidebar and a new mark851 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 shell852 >
A Vercel-like sidebar and a new mark853 <Plus size={14} />
854 New
Agents as a team: lifecycle, merge queue, billing and a new shell855 </DropdownMenuTrigger>
856 <DropdownMenuContent align="end">
857 {shell.repo && (
858 <DropdownMenuItem asChild>
859 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
860 <CircleDot />
861 New issue
862 </Link>
863 </DropdownMenuItem>
864 )}
865 {shell.repo?.member && (
866 <DropdownMenuItem asChild>
867 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
868 <ListTree />
869 Plan work
870 </Link>
871 </DropdownMenuItem>
872 )}
873 <DropdownMenuItem asChild>
874 <Link to="/new">
875 <BookMarked />
876 New repository
877 </Link>
878 </DropdownMenuItem>
879 <DropdownMenuItem asChild>
880 <Link to="/workspaces/new">
881 <Users />
882 New workspace
883 </Link>
884 </DropdownMenuItem>
885 </DropdownMenuContent>
886 </DropdownMenu>
887 </div>
888 </header>
889 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide890 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell891 </div>
892 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
893 </div>
894 );
895}