flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

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

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