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

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