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

904 lines34,656 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 {ws && (
396 <SidebarGroup title="Workspace">
397 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}>
398 Overview
399 </SidebarLink>
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request400 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
401 Usage
402 </SidebarLink>
A catalogue of model providers, and settings that feel like settings403 {/* Everything else about the workspace lives in its settings, and only there. */}
404 <SidebarLink
405 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
406 icon={<Settings size={15} />}
407 >
408 Settings
Integrations: your own model provider, alerts that open issues, tickets agents read409 </SidebarLink>
Agents as a team: lifecycle, merge queue, billing and a new shell410 </SidebarGroup>
411 )}
412
413 <SidebarGroup
414 title="Repositories"
415 action={
416 ws && (
417 <Link
418 to={`/new?workspace=${ws.slug}`}
419 aria-label="New repository"
420 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
421 >
422 <Plus size={13} />
423 </Link>
424 )
425 }
426 >
427 {listed.length === 0 && (
428 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
429 )}
430 {listed.map((repo) => {
431 const open =
432 active && repo.namespace === active.namespace && repo.name === active.name;
433 const base = `/${repo.namespace}/${repo.name}`;
434 return (
435 <div key={base}>
436 <SidebarLink
437 to={base}
438 end={!open}
439 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
440 >
441 <span className="font-mono text-[0.8125rem]">
442 {repo.namespace !== ws?.slug && (
443 <span className="text-faint">{repo.namespace}/</span>
444 )}
445 {repo.name}
446 </span>
447 </SidebarLink>
448 {open && repoBase && (
449 <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2">
450 <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}>
451 Code
452 </SidebarLink>
453 <SidebarLink
454 to={`${repoBase}/issues`}
455 icon={<CircleDot size={14} />}
456 count={active.issues}
457 >
458 Issues
459 </SidebarLink>
460 <SidebarLink
461 to={`${repoBase}/pulls`}
462 also={`${repoBase}/pull`}
463 icon={<GitPullRequest size={14} />}
464 count={active.pulls}
465 >
466 Pull requests
467 </SidebarLink>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs468 <SidebarLink to={`${repoBase}/actions`} icon={<PlayCircle size={14} />}>
469 Actions
470 </SidebarLink>
Agents as a team: lifecycle, merge queue, billing and a new shell471 <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}>
472 Merge queue
473 </SidebarLink>
474 <SidebarLink
475 to={`${repoBase}/commits`}
476 also={`${repoBase}/commit`}
477 icon={<History size={14} />}
478 >
479 Commits
480 </SidebarLink>
481 {active.member && (
482 <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}>
483 Plan
484 </SidebarLink>
485 )}
Automations: rules in .g1t/automations that act when something happens486 <SidebarLink to={`${repoBase}/automations`} icon={<Zap size={14} />}>
487 Automations
488 </SidebarLink>
Agents as a team: lifecycle, merge queue, billing and a new shell489 {active.member && (
490 <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}>
491 Settings
492 </SidebarLink>
493 )}
494 </div>
495 )}
496 </div>
497 );
498 })}
499 </SidebarGroup>
500 </nav>
Polish: phones, copy boxes, the plan page, the landing page, a real glide501 {side.current === "account" || !ws ? (
502 <AccountSettingsMenu open={inAccount} />
503 ) : (
504 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
505 )}
506 </div>
A catalogue of model providers, and settings that feel like settings507 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell508
A Vercel-like sidebar and a new mark509 <div className="space-y-2 p-2">
510 {ws && <UsageCard slug={ws.slug} shell={shell} />}
511 <AccountMenu user={user} />
512 </div>
513 </div>
514 );
515}
516
517/** Words for the sections a path can end in. */
518const SECTIONS: Record<string, string> = {
519 issues: "Issues",
520 pulls: "Pull requests",
521 queue: "Merge queue",
522 commits: "Commits",
523 plans: "Plan",
Automations: rules in .g1t/automations that act when something happens524 automations: "Automations",
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs525 actions: "Actions",
526 secrets: "Secrets and variables",
A Vercel-like sidebar and a new mark527 settings: "Settings",
A catalogue of model providers, and settings that feel like settings528 people: "Members",
A Vercel-like sidebar and a new mark529 tokens: "Access tokens",
530 usage: "Usage",
531 billing: "Billing",
A catalogue of model providers, and settings that feel like settings532 integrations: "Integrations",
Webhooks: every event, to your own addresses, signed and retried533 webhooks: "Webhooks",
A Vercel-like sidebar and a new mark534 tree: "Code",
535 blob: "Code",
536};
537
538/** Where the page is, as a trail of links: workspace / repository / section. */
539function Breadcrumbs({ pathname }: { pathname: string }) {
540 const parts = pathname.split("/").filter(Boolean);
541 const reserved = ["settings", "explore", "new", "search", "workspaces"];
542 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
543 if (reserved.includes(parts[0]!)) {
544 const words: Record<string, string> = {
Account settings in the same shape as a workspace's545 settings: "Account",
A Vercel-like sidebar and a new mark546 explore: "Explore",
547 new: "New repository",
548 search: "Search",
549 workspaces: "New workspace",
550 };
551 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
552 }
553 const [owner, second, third, fourth] = parts;
554 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
555 if (second === "-") {
A catalogue of model providers, and settings that feel like settings556 const page = `/${owner}/-/${third}`;
557 if (third && SETTINGS_PAGE.test(page)) {
558 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
559 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
560 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark561 } else if (second) {
562 const repo = `/${owner}/${second}`;
563 trail.push({ label: second, to: repo, mono: true });
564 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
565 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
566 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
567 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
568 }
569 return (
570 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
571 {trail.map((crumb, index) => (
572 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
573 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell574 <Link
A Vercel-like sidebar and a new mark575 to={crumb.to}
576 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
577 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
578 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell579 >
A Vercel-like sidebar and a new mark580 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell581 </Link>
A Vercel-like sidebar and a new mark582 </span>
583 ))}
584 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell585 );
586}
587
588type Command = { label: string; hint?: string; to: string; icon: ReactNode };
589
590/** Everything the palette can jump to, from what the sidebar already knows. */
591function commandsFor(user: User, shell: ShellData): Command[] {
592 const commands: Command[] = [
593 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
594 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
595 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
596 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
597 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
598 ];
599 const repo = shell.repo;
600 if (repo) {
601 const base = `/${repo.namespace}/${repo.name}`;
602 const name = `${repo.namespace}/${repo.name}`;
603 commands.unshift(
604 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
605 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
606 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
607 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
608 ...(repo.member
609 ? [
610 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
611 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
612 ]
613 : []),
614 );
615 }
616 for (const membership of user.workspaces ?? []) {
617 commands.push(
618 { 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 request619 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings620 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
621 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
622 { 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 shell623 );
624 }
625 for (const listed of shell.repos) {
626 commands.push({
627 label: `${listed.namespace}/${listed.name}`,
628 hint: "Repository",
629 to: `/${listed.namespace}/${listed.name}`,
630 icon: <BookMarked size={15} />,
631 });
632 }
633 return commands;
634}
635
636function CommandPalette({
637 open,
638 onClose,
639 commands,
640}: {
641 open: boolean;
642 onClose: () => void;
643 commands: Command[];
644}) {
645 const navigate = useNavigate();
646 const [query, setQuery] = useState("");
647 const [selected, setSelected] = useState(0);
648 const input = useRef<HTMLInputElement>(null);
649 const matches = useMemo(() => {
650 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
651 return commands
652 .filter((command) => {
653 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
654 return words.every((word) => text.includes(word));
655 })
656 .slice(0, 12);
657 }, [commands, query]);
658
659 useEffect(() => {
660 if (!open) return;
661 setQuery("");
662 setSelected(0);
663 input.current?.focus();
664 }, [open]);
665 useEffect(() => setSelected(0), [query]);
666
667 if (!open) return null;
668 const go = (command: Command | undefined) => {
669 if (!command) return;
670 onClose();
671 navigate(command.to);
672 };
673 return (
674 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
675 <div
676 role="dialog"
677 aria-label="Go to"
678 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
679 onClick={(event) => event.stopPropagation()}
680 >
681 <div className="flex items-center gap-3 border-b border-line px-4">
682 <Search size={16} className="text-faint" />
683 <input
684 ref={input}
685 value={query}
686 onChange={(event) => setQuery(event.target.value)}
687 onKeyDown={(event) => {
688 if (event.key === "ArrowDown") {
689 event.preventDefault();
690 setSelected((index) => Math.min(index + 1, matches.length - 1));
691 } else if (event.key === "ArrowUp") {
692 event.preventDefault();
693 setSelected((index) => Math.max(index - 1, 0));
694 } else if (event.key === "Enter") {
695 event.preventDefault();
696 go(matches[selected]);
697 } else if (event.key === "Escape") {
698 onClose();
699 }
700 }}
701 placeholder="Go to a repository, a page, or an action…"
702 autoComplete="off"
703 data-1p-ignore
704 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"
705 />
706 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
707 </div>
708 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
709 {matches.length === 0 && (
710 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
711 )}
712 {matches.map((command, index) => (
713 <li key={`${command.to}-${command.label}`}>
714 <button
715 type="button"
716 onMouseEnter={() => setSelected(index)}
717 onClick={() => go(command)}
718 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
719 index === selected ? "bg-line text-fg" : "text-muted"
720 }`}
721 >
722 <span className="shrink-0 text-faint">{command.icon}</span>
723 <span className="min-w-0 grow truncate">{command.label}</span>
724 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
725 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
726 </button>
727 </li>
728 ))}
729 </ul>
730 </div>
731 </div>
732 );
733}
734
735/**
736 * A bar across the top of the page while the next one loads, as GitHub
737 * has: it appears at once, creeps towards the end while waiting, then fills
738 * and fades when the page arrives.
739 */
740export function Progress() {
741 const navigation = useNavigation();
742 const busy = navigation.state !== "idle";
743 const [width, setWidth] = useState(0);
744 const [visible, setVisible] = useState(false);
745 useEffect(() => {
746 if (busy) {
747 setVisible(true);
748 setWidth(12);
749 // Slows as it goes, never quite arriving until the page does.
750 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
751 return () => clearInterval(timer);
752 }
753 setWidth((now) => (now > 0 ? 100 : 0));
754 const fade = setTimeout(() => setVisible(false), 250);
755 const reset = setTimeout(() => setWidth(0), 550);
756 return () => {
757 clearTimeout(fade);
758 clearTimeout(reset);
759 };
760 }, [busy]);
761 return (
762 <div
763 aria-hidden="true"
764 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
765 visible ? "opacity-100" : "opacity-0"
766 }`}
767 >
768 <div
769 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
770 style={{ width: `${width}%` }}
771 />
772 </div>
773 );
774}
775
776/**
777 * The signed-in app: a sidebar with the workspace, its repositories and the
778 * sections of the one being looked at; a slim bar with search and the
779 * account; and the page.
780 */
781export function AppShell({
782 user,
783 shell,
784 banner,
785 children,
786}: {
787 user: User;
788 shell: ShellData;
789 banner?: ReactNode;
790 children: ReactNode;
791}) {
792 const submit = useSubmit();
793 const { pathname } = useLocation();
794 const [drawer, setDrawer] = useState(false);
795 const [palette, setPalette] = useState(false);
796 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
797
798 // A new page closes the drawer on small screens.
799 useEffect(() => setDrawer(false), [pathname]);
800 useEffect(() => {
801 const onKey = (event: KeyboardEvent) => {
802 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
803 event.preventDefault();
804 setPalette((open) => !open);
805 }
806 };
807 window.addEventListener("keydown", onKey);
808 return () => window.removeEventListener("keydown", onKey);
809 }, []);
810
811 return (
812 <div className="min-h-screen">
813 <Progress />
814 <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 mark815 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell816 </aside>
817 {drawer && (
818 <div className="fixed inset-0 z-50 lg:hidden">
819 <button
820 type="button"
821 aria-label="Close menu"
822 className="absolute inset-0 bg-black/60"
823 onClick={() => setDrawer(false)}
824 />
825 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
826 <button
827 type="button"
828 aria-label="Close menu"
829 onClick={() => setDrawer(false)}
830 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
831 >
832 <X size={16} />
833 </button>
A Vercel-like sidebar and a new mark834 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell835 </aside>
836 </div>
837 )}
838
Polish: phones, copy boxes, the plan page, the landing page, a real glide839 <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 shell840 <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">
841 <button
842 type="button"
843 aria-label="Open menu"
844 onClick={() => setDrawer(true)}
845 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
846 >
847 <Menu size={18} />
848 </button>
A Vercel-like sidebar and a new mark849 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell850 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark851 <a
852 href="https://docs.g1t.sh/"
853 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
854 >
855 Docs
856 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell857 <DropdownMenu>
858 <DropdownMenuTrigger
859 aria-label="Create"
A Vercel-like sidebar and a new mark860 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 shell861 >
A Vercel-like sidebar and a new mark862 <Plus size={14} />
863 New
Agents as a team: lifecycle, merge queue, billing and a new shell864 </DropdownMenuTrigger>
865 <DropdownMenuContent align="end">
866 {shell.repo && (
867 <DropdownMenuItem asChild>
868 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
869 <CircleDot />
870 New issue
871 </Link>
872 </DropdownMenuItem>
873 )}
874 {shell.repo?.member && (
875 <DropdownMenuItem asChild>
876 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
877 <ListTree />
878 Plan work
879 </Link>
880 </DropdownMenuItem>
881 )}
882 <DropdownMenuItem asChild>
883 <Link to="/new">
884 <BookMarked />
885 New repository
886 </Link>
887 </DropdownMenuItem>
888 <DropdownMenuItem asChild>
889 <Link to="/workspaces/new">
890 <Users />
891 New workspace
892 </Link>
893 </DropdownMenuItem>
894 </DropdownMenuContent>
895 </DropdownMenu>
896 </div>
897 </header>
898 {banner}
Polish: phones, copy boxes, the plan page, the landing page, a real glide899 <main className="min-w-0 grow">{children}</main>
Agents as a team: lifecycle, merge queue, billing and a new shell900 </div>
901 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
902 </div>
903 );
904}