flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

873 lines33,543 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
247/** How the sidebar's two menus slide past each other. */
248const SLIDE = "absolute inset-0 overflow-y-auto px-2 pb-4 transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none";
249
250/** A workspace's settings, as the sidebar shows them in place of everything else. */
251function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
252 return (
253 <nav
254 aria-label="Workspace settings"
255 inert={!open}
256 className={`${SLIDE} ${open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}
257 >
258 <Link
259 to={`/${slug}`}
260 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"
261 >
262 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
263 <span className="truncate">{slug}</span>
264 </Link>
265 <SidebarGroup title="Settings">
266 {owner && (
267 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
268 General
269 </SidebarLink>
270 )}
271 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
272 Members
273 </SidebarLink>
274 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
275 Billing
276 </SidebarLink>
277 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
278 Integrations
279 </SidebarLink>
280 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
281 Access tokens
282 </SidebarLink>
283 </SidebarGroup>
284 </nav>
285 );
286}
287
Account settings in the same shape as a workspace's288/** Your own settings, as the sidebar shows them on the settings page. */
289function AccountSettingsMenu({ open }: { open: boolean }) {
290 const { hash } = useLocation();
291 const item = (id: string, icon: ReactNode, label: string) => (
292 <Link
293 to={`/settings#${id}`}
294 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
295 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
296 }`}
297 >
298 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
299 {label}
300 </Link>
301 );
302 return (
303 <nav
304 aria-label="Your settings"
305 inert={!open}
306 className={`${SLIDE} ${open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}
307 >
308 <Link
309 to="/"
310 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"
311 >
312 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
313 Mission control
314 </Link>
315 <SidebarGroup title="Account">
316 {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
317 {item("tokens", <Lock size={15} />, "Access tokens")}
318 {item("applications", <Plug size={15} />, "Connected applications")}
319 </SidebarGroup>
320 </nav>
321 );
322}
323
A Vercel-like sidebar and a new mark324function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
Agents as a team: lifecycle, merge queue, billing and a new shell325 const ws = shell.workspace;
A catalogue of model providers, and settings that feel like settings326 const { pathname } = useLocation();
327 const going = useNavigation().location?.pathname;
328 // On a workspace's settings page, or on the way to one, its settings take
329 // the sidebar over.
330 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
Account settings in the same shape as a workspace's331 const inAccount = (going ?? pathname) === "/settings";
332 const away = inSettings || inAccount;
Agents as a team: lifecycle, merge queue, billing and a new shell333 const active = shell.repo;
334 const repoBase = active ? `/${active.namespace}/${active.name}` : null;
335 // The repository being looked at is listed even when it is someone else's.
336 const listed =
337 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
338 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
339 : shell.repos;
340 return (
341 <div className="flex h-full flex-col">
A Vercel-like sidebar and a new mark342 {/* The same height and rule as the top bar, so the two read as one line. */}
343 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
344 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
345 <Mark className="size-5" />
Agents as a team: lifecycle, merge queue, billing and a new shell346 </Link>
A Vercel-like sidebar and a new mark347 <span className="shrink-0 text-line-strong" aria-hidden="true">
348 /
349 </span>
350 <WorkspaceSwitcher user={user} shell={shell} />
Agents as a team: lifecycle, merge queue, billing and a new shell351 </div>
A Vercel-like sidebar and a new mark352 <div className="px-2 pt-3">
353 <button
354 type="button"
355 onClick={onFind}
356 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"
357 >
358 <Search size={14} />
359 <span className="grow text-left">Find…</span>
360 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
361 </button>
Agents as a team: lifecycle, merge queue, billing and a new shell362 </div>
A catalogue of model providers, and settings that feel like settings363 <div className="relative min-h-0 grow overflow-hidden">
364 <nav
365 aria-label="g1t"
Account settings in the same shape as a workspace's366 inert={away}
367 className={`${SLIDE} ${away ? "pointer-events-none -translate-x-1/3 opacity-0" : "translate-x-0 opacity-100"}`}
A catalogue of model providers, and settings that feel like settings368 >
Agents as a team: lifecycle, merge queue, billing and a new shell369 <div className="mt-3 space-y-px">
370 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
371 Mission control
372 </SidebarLink>
373 <SidebarLink to="/explore" icon={<Compass size={15} />}>
374 Explore
375 </SidebarLink>
376 </div>
377
378 {ws && (
379 <SidebarGroup title="Workspace">
380 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}>
381 Overview
382 </SidebarLink>
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request383 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
384 Usage
385 </SidebarLink>
A catalogue of model providers, and settings that feel like settings386 {/* Everything else about the workspace lives in its settings, and only there. */}
387 <SidebarLink
388 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
389 icon={<Settings size={15} />}
390 >
391 Settings
Integrations: your own model provider, alerts that open issues, tickets agents read392 </SidebarLink>
Agents as a team: lifecycle, merge queue, billing and a new shell393 </SidebarGroup>
394 )}
395
396 <SidebarGroup
397 title="Repositories"
398 action={
399 ws && (
400 <Link
401 to={`/new?workspace=${ws.slug}`}
402 aria-label="New repository"
403 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
404 >
405 <Plus size={13} />
406 </Link>
407 )
408 }
409 >
410 {listed.length === 0 && (
411 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
412 )}
413 {listed.map((repo) => {
414 const open =
415 active && repo.namespace === active.namespace && repo.name === active.name;
416 const base = `/${repo.namespace}/${repo.name}`;
417 return (
418 <div key={base}>
419 <SidebarLink
420 to={base}
421 end={!open}
422 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
423 >
424 <span className="font-mono text-[0.8125rem]">
425 {repo.namespace !== ws?.slug && (
426 <span className="text-faint">{repo.namespace}/</span>
427 )}
428 {repo.name}
429 </span>
430 </SidebarLink>
431 {open && repoBase && (
432 <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2">
433 <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}>
434 Code
435 </SidebarLink>
436 <SidebarLink
437 to={`${repoBase}/issues`}
438 icon={<CircleDot size={14} />}
439 count={active.issues}
440 >
441 Issues
442 </SidebarLink>
443 <SidebarLink
444 to={`${repoBase}/pulls`}
445 also={`${repoBase}/pull`}
446 icon={<GitPullRequest size={14} />}
447 count={active.pulls}
448 >
449 Pull requests
450 </SidebarLink>
451 <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}>
452 Merge queue
453 </SidebarLink>
454 <SidebarLink
455 to={`${repoBase}/commits`}
456 also={`${repoBase}/commit`}
457 icon={<History size={14} />}
458 >
459 Commits
460 </SidebarLink>
461 {active.member && (
462 <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}>
463 Plan
464 </SidebarLink>
465 )}
466 {active.member && (
467 <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}>
468 Settings
469 </SidebarLink>
470 )}
471 </div>
472 )}
473 </div>
474 );
475 })}
476 </SidebarGroup>
477 </nav>
A catalogue of model providers, and settings that feel like settings478 {ws && <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />}
Account settings in the same shape as a workspace's479 <AccountSettingsMenu open={inAccount} />
A catalogue of model providers, and settings that feel like settings480 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell481
A Vercel-like sidebar and a new mark482 <div className="space-y-2 p-2">
483 {ws && <UsageCard slug={ws.slug} shell={shell} />}
484 <AccountMenu user={user} />
485 </div>
486 </div>
487 );
488}
489
490/** Words for the sections a path can end in. */
491const SECTIONS: Record<string, string> = {
492 issues: "Issues",
493 pulls: "Pull requests",
494 queue: "Merge queue",
495 commits: "Commits",
496 plans: "Plan",
497 settings: "Settings",
A catalogue of model providers, and settings that feel like settings498 people: "Members",
A Vercel-like sidebar and a new mark499 tokens: "Access tokens",
500 usage: "Usage",
501 billing: "Billing",
A catalogue of model providers, and settings that feel like settings502 integrations: "Integrations",
A Vercel-like sidebar and a new mark503 tree: "Code",
504 blob: "Code",
505};
506
507/** Where the page is, as a trail of links: workspace / repository / section. */
508function Breadcrumbs({ pathname }: { pathname: string }) {
509 const parts = pathname.split("/").filter(Boolean);
510 const reserved = ["settings", "explore", "new", "search", "workspaces"];
511 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
512 if (reserved.includes(parts[0]!)) {
513 const words: Record<string, string> = {
Account settings in the same shape as a workspace's514 settings: "Account",
A Vercel-like sidebar and a new mark515 explore: "Explore",
516 new: "New repository",
517 search: "Search",
518 workspaces: "New workspace",
519 };
520 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
521 }
522 const [owner, second, third, fourth] = parts;
523 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
524 if (second === "-") {
A catalogue of model providers, and settings that feel like settings525 const page = `/${owner}/-/${third}`;
526 if (third && SETTINGS_PAGE.test(page)) {
527 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
528 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
529 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
A Vercel-like sidebar and a new mark530 } else if (second) {
531 const repo = `/${owner}/${second}`;
532 trail.push({ label: second, to: repo, mono: true });
533 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
534 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
535 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
536 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
537 }
538 return (
539 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
540 {trail.map((crumb, index) => (
541 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
542 {index > 0 && <span className="text-line-strong">/</span>}
Agents as a team: lifecycle, merge queue, billing and a new shell543 <Link
A Vercel-like sidebar and a new mark544 to={crumb.to}
545 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
546 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
547 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
Agents as a team: lifecycle, merge queue, billing and a new shell548 >
A Vercel-like sidebar and a new mark549 {crumb.label}
Agents as a team: lifecycle, merge queue, billing and a new shell550 </Link>
A Vercel-like sidebar and a new mark551 </span>
552 ))}
553 </nav>
Agents as a team: lifecycle, merge queue, billing and a new shell554 );
555}
556
557type Command = { label: string; hint?: string; to: string; icon: ReactNode };
558
559/** Everything the palette can jump to, from what the sidebar already knows. */
560function commandsFor(user: User, shell: ShellData): Command[] {
561 const commands: Command[] = [
562 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
563 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
564 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
565 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
566 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
567 ];
568 const repo = shell.repo;
569 if (repo) {
570 const base = `/${repo.namespace}/${repo.name}`;
571 const name = `${repo.namespace}/${repo.name}`;
572 commands.unshift(
573 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
574 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
575 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
576 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
577 ...(repo.member
578 ? [
579 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
580 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
581 ]
582 : []),
583 );
584 }
585 for (const membership of user.workspaces ?? []) {
586 commands.push(
587 { 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 request588 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
A catalogue of model providers, and settings that feel like settings589 { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
590 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
591 { 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 shell592 );
593 }
594 for (const listed of shell.repos) {
595 commands.push({
596 label: `${listed.namespace}/${listed.name}`,
597 hint: "Repository",
598 to: `/${listed.namespace}/${listed.name}`,
599 icon: <BookMarked size={15} />,
600 });
601 }
602 return commands;
603}
604
605function CommandPalette({
606 open,
607 onClose,
608 commands,
609}: {
610 open: boolean;
611 onClose: () => void;
612 commands: Command[];
613}) {
614 const navigate = useNavigate();
615 const [query, setQuery] = useState("");
616 const [selected, setSelected] = useState(0);
617 const input = useRef<HTMLInputElement>(null);
618 const matches = useMemo(() => {
619 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
620 return commands
621 .filter((command) => {
622 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
623 return words.every((word) => text.includes(word));
624 })
625 .slice(0, 12);
626 }, [commands, query]);
627
628 useEffect(() => {
629 if (!open) return;
630 setQuery("");
631 setSelected(0);
632 input.current?.focus();
633 }, [open]);
634 useEffect(() => setSelected(0), [query]);
635
636 if (!open) return null;
637 const go = (command: Command | undefined) => {
638 if (!command) return;
639 onClose();
640 navigate(command.to);
641 };
642 return (
643 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
644 <div
645 role="dialog"
646 aria-label="Go to"
647 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
648 onClick={(event) => event.stopPropagation()}
649 >
650 <div className="flex items-center gap-3 border-b border-line px-4">
651 <Search size={16} className="text-faint" />
652 <input
653 ref={input}
654 value={query}
655 onChange={(event) => setQuery(event.target.value)}
656 onKeyDown={(event) => {
657 if (event.key === "ArrowDown") {
658 event.preventDefault();
659 setSelected((index) => Math.min(index + 1, matches.length - 1));
660 } else if (event.key === "ArrowUp") {
661 event.preventDefault();
662 setSelected((index) => Math.max(index - 1, 0));
663 } else if (event.key === "Enter") {
664 event.preventDefault();
665 go(matches[selected]);
666 } else if (event.key === "Escape") {
667 onClose();
668 }
669 }}
670 placeholder="Go to a repository, a page, or an action…"
671 autoComplete="off"
672 data-1p-ignore
673 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"
674 />
675 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
676 </div>
677 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
678 {matches.length === 0 && (
679 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
680 )}
681 {matches.map((command, index) => (
682 <li key={`${command.to}-${command.label}`}>
683 <button
684 type="button"
685 onMouseEnter={() => setSelected(index)}
686 onClick={() => go(command)}
687 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
688 index === selected ? "bg-line text-fg" : "text-muted"
689 }`}
690 >
691 <span className="shrink-0 text-faint">{command.icon}</span>
692 <span className="min-w-0 grow truncate">{command.label}</span>
693 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
694 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
695 </button>
696 </li>
697 ))}
698 </ul>
699 </div>
700 </div>
701 );
702}
703
704/**
705 * A bar across the top of the page while the next one loads, as GitHub
706 * has: it appears at once, creeps towards the end while waiting, then fills
707 * and fades when the page arrives.
708 */
709export function Progress() {
710 const navigation = useNavigation();
711 const busy = navigation.state !== "idle";
712 const [width, setWidth] = useState(0);
713 const [visible, setVisible] = useState(false);
714 useEffect(() => {
715 if (busy) {
716 setVisible(true);
717 setWidth(12);
718 // Slows as it goes, never quite arriving until the page does.
719 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
720 return () => clearInterval(timer);
721 }
722 setWidth((now) => (now > 0 ? 100 : 0));
723 const fade = setTimeout(() => setVisible(false), 250);
724 const reset = setTimeout(() => setWidth(0), 550);
725 return () => {
726 clearTimeout(fade);
727 clearTimeout(reset);
728 };
729 }, [busy]);
730 return (
731 <div
732 aria-hidden="true"
733 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
734 visible ? "opacity-100" : "opacity-0"
735 }`}
736 >
737 <div
738 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
739 style={{ width: `${width}%` }}
740 />
741 </div>
742 );
743}
744
745/**
746 * The signed-in app: a sidebar with the workspace, its repositories and the
747 * sections of the one being looked at; a slim bar with search and the
748 * account; and the page.
749 */
750export function AppShell({
751 user,
752 shell,
753 banner,
754 children,
755}: {
756 user: User;
757 shell: ShellData;
758 banner?: ReactNode;
759 children: ReactNode;
760}) {
761 const submit = useSubmit();
762 const { pathname } = useLocation();
763 const [drawer, setDrawer] = useState(false);
764 const [palette, setPalette] = useState(false);
765 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
766
767 // A new page closes the drawer on small screens.
768 useEffect(() => setDrawer(false), [pathname]);
769 useEffect(() => {
770 const onKey = (event: KeyboardEvent) => {
771 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
772 event.preventDefault();
773 setPalette((open) => !open);
774 }
775 };
776 window.addEventListener("keydown", onKey);
777 return () => window.removeEventListener("keydown", onKey);
778 }, []);
779
780 return (
781 <div className="min-h-screen">
782 <Progress />
783 <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 mark784 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell785 </aside>
786 {drawer && (
787 <div className="fixed inset-0 z-50 lg:hidden">
788 <button
789 type="button"
790 aria-label="Close menu"
791 className="absolute inset-0 bg-black/60"
792 onClick={() => setDrawer(false)}
793 />
794 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
795 <button
796 type="button"
797 aria-label="Close menu"
798 onClick={() => setDrawer(false)}
799 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
800 >
801 <X size={16} />
802 </button>
A Vercel-like sidebar and a new mark803 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
Agents as a team: lifecycle, merge queue, billing and a new shell804 </aside>
805 </div>
806 )}
807
808 <div className="flex min-h-screen flex-col lg:pl-64">
809 <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">
810 <button
811 type="button"
812 aria-label="Open menu"
813 onClick={() => setDrawer(true)}
814 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
815 >
816 <Menu size={18} />
817 </button>
A Vercel-like sidebar and a new mark818 <Breadcrumbs pathname={pathname} />
Agents as a team: lifecycle, merge queue, billing and a new shell819 <div className="ml-auto flex items-center gap-1.5">
A Vercel-like sidebar and a new mark820 <a
821 href="https://docs.g1t.sh/"
822 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
823 >
824 Docs
825 </a>
Agents as a team: lifecycle, merge queue, billing and a new shell826 <DropdownMenu>
827 <DropdownMenuTrigger
828 aria-label="Create"
A Vercel-like sidebar and a new mark829 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 shell830 >
A Vercel-like sidebar and a new mark831 <Plus size={14} />
832 New
Agents as a team: lifecycle, merge queue, billing and a new shell833 </DropdownMenuTrigger>
834 <DropdownMenuContent align="end">
835 {shell.repo && (
836 <DropdownMenuItem asChild>
837 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
838 <CircleDot />
839 New issue
840 </Link>
841 </DropdownMenuItem>
842 )}
843 {shell.repo?.member && (
844 <DropdownMenuItem asChild>
845 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
846 <ListTree />
847 Plan work
848 </Link>
849 </DropdownMenuItem>
850 )}
851 <DropdownMenuItem asChild>
852 <Link to="/new">
853 <BookMarked />
854 New repository
855 </Link>
856 </DropdownMenuItem>
857 <DropdownMenuItem asChild>
858 <Link to="/workspaces/new">
859 <Users />
860 New workspace
861 </Link>
862 </DropdownMenuItem>
863 </DropdownMenuContent>
864 </DropdownMenu>
865 </div>
866 </header>
867 {banner}
868 <main className="grow">{children}</main>
869 </div>
870 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
871 </div>
872 );
873}