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

1,647 lines64,790 bytesCodeBlame
1import {
2 Activity,
3 ServerCog,
4 GanttChart,
5 KanbanSquare,
6 Package,
7 Sparkles,
8 BarChart3,
9 BookMarked,
10 BookOpen,
11 Check,
12 ChevronLeft,
13 ChevronRight,
14 ChevronsUpDown,
15 CircleUserRound,
16 Mail,
17 Ticket,
18 CircleDot,
19 Code2,
20 Compass,
21 CreditCard,
22 GitPullRequest,
23 History,
24 House,
25 Fingerprint,
26 Gauge,
27 KeyRound,
28 LifeBuoy,
29 Box,
30 LayoutGrid,
31 ListTree,
32 Lock,
33 LogIn,
34 LogOut,
35 Menu,
36 Plus,
37 Search,
38 Plug,
39 Settings,
40 Users,
41 Webhook,
42 Globe,
43 GitBranch,
44 PlayCircle,
45 Bot,
46 Brain,
47 Network,
48 ShieldCheck,
49 Rocket,
50 X,
51} from "lucide-react";
52import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
53import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
54
55import type { Abilities, Membership, Spike, User } from "@g1t/contracts";
56
57import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./command-palette";
58import { StatusDot, useSiteStatus } from "./footer";
59import { Logo } from "./logo";
60import { Avatar, notACredential } from "./ui";
61import { Skeleton } from "./ui/skeleton";
62import {
63 DropdownMenu,
64 DropdownMenuContent,
65 DropdownMenuGroup,
66 DropdownMenuItem,
67 DropdownMenuLabel,
68 DropdownMenuSeparator,
69 DropdownMenuTrigger,
70} from "./ui/dropdown-menu";
71import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
72import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access";
73import { VISITOR_LINKS, projectPages } from "../lib/chrome";
74import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings";
75import { GithubMark } from "./github";
76import { withNext } from "../lib/next";
77import { useSignUpCopy } from "../lib/registration";
78import { STATUS_URL, statusTitle } from "../lib/status";
79import type { AccountMenuData } from "../routes/settings-menu-json";
80
81/**
82 * What the sidebar needs, worked out by the root loader. For a visitor who
83 * is not signed in there is no workspace, no projects and no usage: only
84 * the project being looked at, if they can see it.
85 */
86export type ShellData = {
87 /** The workspace the sidebar is about: the one being looked at, or their first. */
88 workspace: Membership | null;
89 /** Its projects, by name: `name` is the slug in their address. */
90 repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
91 /** Repositories shared with them in workspaces they do not belong to. */
92 shared?: { namespace: string; name: string; isPrivate: boolean }[];
93 /** The project being looked at, if any, whoever owns it. */
94 repo: {
95 namespace: string;
96 name: string;
97 member: boolean;
98 issues: number;
99 pulls: number;
100 } | null;
101 /** Where the workspace stands against its usage limit, if billing is on. */
102 limit: {
103 exposureMicros: number;
104 ceilingMicros: number | null;
105 state: "ok" | "warning" | "stopped";
106 comped: boolean;
107 } | null;
108 /** Whether g1t charges nothing for now, while it is being built out. */
109 free?: boolean;
110 /** What its agents have cost since the start of the month. */
111 /** This month's usage: charged, or at cost while g1t is free. */
112 monthUsageMicros: number | null;
113 /** Whether new compute is paused (a spend spike or a hold), and whether the viewer can answer it. */
114 compute?: { paused: string | null; spike: Spike | null; owner: boolean } | null;
115};
116
117function SidebarLink({
118 to,
119 icon,
120 end,
121 count,
122 also,
123 drill,
124 children,
125}: {
126 to: string;
127 icon: ReactNode;
128 end?: boolean;
129 count?: number;
130 /** Other path prefixes under which this link is the current one. */
131 also?: string | string[];
132 /** It opens a list of its own: a chevron says so, always or on hover. */
133 drill?: boolean | "hover";
134 children: ReactNode;
135}) {
136 const { pathname } = useLocation();
137 return (
138 <NavLink
139 to={to}
140 end={end}
141 prefetch="intent"
142 className={({ isActive, isPending }) => {
143 const current =
144 isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
145 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
146 current
147 ? "bg-raised font-medium text-fg"
148 : isPending
149 ? "bg-raised/60 text-fg"
150 : "text-muted hover:bg-raised/60 hover:text-fg"
151 }`;
152 }}
153 >
154 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
155 <span className="min-w-0 grow truncate">{children}</span>
156 {count != null && count > 0 && (
157 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
158 {count}
159 </span>
160 )}
161 {drill && (
162 <ChevronRight
163 size={14}
164 aria-hidden="true"
165 className={`-mr-0.5 shrink-0 text-faint transition-opacity ${drill === "hover" ? "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" : ""}`}
166 />
167 )}
168 </NavLink>
169 );
170}
171
172/**
173 * Something a project will have, shown where it will live so people can
174 * see where g1t is going. Not a link: nothing is behind it yet.
175 */
176function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
177 return (
178 <div
179 title={about}
180 aria-disabled="true"
181 className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint"
182 >
183 <span className="shrink-0 opacity-70">{icon}</span>
184 <span className="grow truncate">{children}</span>
185 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
186 Soon
187 </span>
188 </div>
189 );
190}
191
192/**
193 * A page that is coming, as a link to its roadmap page: faint, with Soon,
194 * and marked current on any of its section's Soon pages.
195 */
196function SidebarSoonLink({
197 to,
198 also,
199 icon,
200 about,
201 children,
202}: {
203 to: string;
204 also?: string[];
205 icon: ReactNode;
206 about: string;
207 children: ReactNode;
208}) {
209 const { pathname } = useLocation();
210 return (
211 <NavLink
212 to={to}
213 title={about}
214 prefetch="intent"
215 className={({ isActive }) => {
216 const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
217 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
218 current ? "bg-raised font-medium text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"
219 }`;
220 }}
221 >
222 <span className="shrink-0 opacity-80">{icon}</span>
223 <span className="min-w-0 grow truncate">{children}</span>
224 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
225 Soon
226 </span>
227 </NavLink>
228 );
229}
230
231/** Icons for what spans projects. */
232const WORKSPACE_ICONS: Record<string, ReactNode> = {
233 board: <KanbanSquare size={15} />,
234 roadmap: <GanttChart size={15} />,
235 packages: <Package size={15} />,
236 fleet: <Bot size={15} />,
237};
238
239/** The Soon pages of a section, as paths, so its link is current on them. */
240function soonPaths(base: string, section: RoadmapItem["section"]): string[] {
241 return roadmapIn(section).map((item) => `${base}/soon/${item.key}`);
242}
243
244function SidebarGroup({
245 title,
246 action,
247 className = "",
248 children,
249}: {
250 title: string;
251 action?: ReactNode;
252 className?: string;
253 children: ReactNode;
254}) {
255 return (
256 <section className={className}>
257 <div className="mb-1 flex h-6 items-center justify-between px-2">
258 <h2 className="text-xs font-medium text-faint">{title}</h2>
259 {action}
260 </div>
261 <div className="space-y-px">{children}</div>
262 </section>
263 );
264}
265
266/** What a workspace is called where people read it: its display name, else its slug. */
267function displayName(membership: Membership): string {
268 return membership.name?.trim() || membership.slug;
269}
270
271function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
272 const workspace = shell.workspace;
273 const current = workspace?.slug;
274 const label = workspace ? displayName(workspace) : "Choose a workspace";
275 return (
276 <DropdownMenu>
277 {/* The name goes to the workspace; only the arrows switch it. The
278 name gets all the room there is, and the whole of it on hover. */}
279 <Link
280 to={current ? `/${current}` : "/workspaces/new"}
281 prefetch="intent"
282 title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}
283 className="flex h-9 min-w-0 grow items-center gap-2 rounded-md px-2 transition-colors hover:bg-raised"
284 >
285 {workspace ? (
286 <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
287 ) : (
288 <span className="size-5 shrink-0 rounded-md border border-dashed border-line-strong" />
289 )}
290 <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
291 </Link>
292 <DropdownMenuTrigger
293 aria-label="Switch workspace"
294 className="flex h-9 w-7 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"
295 >
296 <ChevronsUpDown size={14} />
297 </DropdownMenuTrigger>
298 <DropdownMenuContent align="start" className="w-64">
299 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
300 {(user.workspaces ?? []).map((membership) => (
301 <DropdownMenuItem asChild key={membership.slug}>
302 <Link to={`/${membership.slug}`} title={displayName(membership)}>
303 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
304 <span className="flex min-w-0 grow flex-col leading-tight">
305 <span className="truncate">{displayName(membership)}</span>
306 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
307 </span>
308 <span className="shrink-0 text-[0.6875rem] text-faint capitalize">{membership.role}</span>
309 {membership.slug === current ? (
310 <Check className="shrink-0 text-accent" />
311 ) : (
312 <span className="size-4 shrink-0" aria-hidden="true" />
313 )}
314 </Link>
315 </DropdownMenuItem>
316 ))}
317 <DropdownMenuSeparator />
318 <DropdownMenuItem asChild>
319 <Link to="/workspaces/new">
320 <Plus />
321 New workspace
322 </Link>
323 </DropdownMenuItem>
324 </DropdownMenuContent>
325 </DropdownMenu>
326 );
327}
328
329/**
330 * The fine print, as one quiet row at the foot of the account menu and the
331 * visitor's panel: each link a full-height target, left-aligned. In the
332 * menu each is a menu item, so the arrow keys reach it.
333 */
334const LEGAL_LINKS: [string, string][] = [
335 ["Policies", "/policies"],
336 ["Privacy", "/policies/privacy"],
337 ["Security", "/security"],
338];
339const LEGAL_LINK = "flex h-7 items-center rounded px-1.5 text-xs text-faint outline-none transition-colors";
340
341function MenuLegalRow() {
342 return (
343 <div role="group" aria-label="Legal" className="flex items-center gap-0.5 px-0.5 pt-0.5">
344 {LEGAL_LINKS.map(([label, to], index) => (
345 <span key={to} className="flex items-center gap-0.5">
346 {index > 0 && (
347 <span aria-hidden="true" className="text-[0.625rem] text-line-strong">
348 ·
349 </span>
350 )}
351 <DropdownMenuItem asChild className={`${LEGAL_LINK} py-0 data-highlighted:bg-line data-highlighted:text-fg`}>
352 <Link to={to}>{label}</Link>
353 </DropdownMenuItem>
354 </span>
355 ))}
356 </div>
357 );
358}
359
360/** Status's row: the live dot and two or three words, fetched when the menu opens. */
361function StatusSummary({ open }: { open: boolean }) {
362 const status = useSiteStatus(open);
363 const words = statusTitle(status);
364 return (
365 <span className="ml-auto flex min-w-0 items-center gap-1.5 pl-2 text-xs text-faint">
366 {/* The words say it; the dot's own words for screen readers would repeat them. */}
367 <span aria-hidden="true" className="flex">
368 <StatusDot state={status?.overall.state ?? null} />
369 </span>
370 {words ? <span className="truncate">{words}</span> : <Skeleton className="h-3 w-20" />}
371 </span>
372 );
373}
374
375/** A menu row: an icon, words, and whatever sits at its end. */
376const MENU_ROW = "h-9 gap-2.5 px-2.5 text-[0.8125rem]";
377
378function AccountMenu({ user }: { user: User }) {
379 const submit = useSubmit();
380 const [open, setOpen] = useState(false);
381 // Name, primary address and invites left: asked for once, as soon as the
382 // pointer or focus reaches the button, so they are there when it opens.
383 const details = useFetcher<AccountMenuData | null>({ key: "account-menu" });
384 const prefetch = () => {
385 if (details.state === "idle" && details.data === undefined) details.load("/settings/menu.json");
386 };
387 useEffect(() => {
388 if (open) prefetch();
389 // eslint-disable-next-line react-hooks/exhaustive-deps
390 }, [open]);
391 const me = details.data ?? null;
392 // Still on its way: shapes where the words will be, the same size.
393 const loading = details.data === undefined && details.state !== "idle";
394 const profile = `/u/${user.username}`;
395 return (
396 <DropdownMenu open={open} onOpenChange={setOpen}>
397 <DropdownMenuTrigger
398 aria-label={`Account menu for ${user.username}`}
399 onPointerEnter={prefetch}
400 onFocus={prefetch}
401 className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised focus-visible:ring-2 focus-visible:ring-merged/60 data-[state=open]:bg-raised"
402 >
403 <Avatar name={user.username} image={user.avatar} size={22} />
404 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
405 <ChevronsUpDown size={14} className="shrink-0 text-faint" />
406 </DropdownMenuTrigger>
407 <DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-[17.5rem] p-1.5">
408 {/* Who is signed in, and a way to their profile. */}
409 <DropdownMenuItem asChild className="gap-3 px-2 py-2">
410 <Link to={profile} aria-label={`${me?.name ?? user.username} (@${user.username}), your profile`}>
411 <Avatar name={user.username} image={user.avatar} size={36} />
412 <span className="flex min-w-0 grow flex-col leading-tight" aria-busy={loading}>
413 {loading ? (
414 <Skeleton className="my-[0.1875rem] h-3.5 w-28" />
415 ) : (
416 <span className="truncate text-sm font-medium text-fg">{me?.name ?? user.username}</span>
417 )}
418 <span className="truncate font-mono text-xs text-muted">@{user.username}</span>
419 {loading ? (
420 <Skeleton className="mt-1 h-3 w-40" />
421 ) : (
422 me?.email && <span className="mt-0.5 truncate text-xs text-faint">{me.email}</span>
423 )}
424 </span>
425 </Link>
426 </DropdownMenuItem>
427 <DropdownMenuSeparator className="my-1.5" />
428 <DropdownMenuGroup>
429 <DropdownMenuItem asChild className={MENU_ROW}>
430 <Link to={profile}>
431 <CircleUserRound />
432 Your profile
433 </Link>
434 </DropdownMenuItem>
435 <DropdownMenuItem asChild className={MENU_ROW}>
436 <Link to={FIRST_SETTINGS_PAGE}>
437 <Settings />
438 Your settings
439 </Link>
440 </DropdownMenuItem>
441 <DropdownMenuItem asChild className={MENU_ROW}>
442 <Link to="/settings/invites">
443 <Ticket />
444 Invites
445 {loading && <Skeleton className="ml-auto h-4 w-12 rounded-full" />}
446 {me?.invites_left != null && (
447 <span className="ml-auto rounded-full bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
448 {me.invites_left} left
449 </span>
450 )}
451 </Link>
452 </DropdownMenuItem>
453 </DropdownMenuGroup>
454 <DropdownMenuSeparator className="my-1.5" />
455 <DropdownMenuGroup>
456 <DropdownMenuItem asChild className={MENU_ROW}>
457 <a href="https://docs.g1t.sh/">
458 <BookOpen />
459 Documentation
460 </a>
461 </DropdownMenuItem>
462 <DropdownMenuItem asChild className={MENU_ROW}>
463 <Link to="/support">
464 <LifeBuoy />
465 Support
466 </Link>
467 </DropdownMenuItem>
468 <DropdownMenuItem asChild className={MENU_ROW}>
469 <a href={STATUS_URL}>
470 <Activity />
471 Status
472 <StatusSummary open={open} />
473 </a>
474 </DropdownMenuItem>
475 </DropdownMenuGroup>
476 <DropdownMenuSeparator className="my-1.5" />
477 {/* Submitted from here: the menu closes on select, and a button
478 that has left the page cannot submit a form. */}
479 <DropdownMenuItem className={MENU_ROW} onSelect={() => submit(null, { method: "post", action: "/logout" })}>
480 <LogOut />
481 Sign out
482 </DropdownMenuItem>
483 <DropdownMenuSeparator className="my-1.5" />
484 <MenuLegalRow />
485 </DropdownMenuContent>
486 </DropdownMenu>
487 );
488}
489
490/**
491 * A workspace's settings pages, which the sidebar drills into: who belongs,
492 * what it pays, and its record. What it builds and runs with (secrets,
493 * integrations, webhooks, guardrails) sits in the main list.
494 */
495const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|repositories|tokens|billing|audit)(\/|$)/;
496/** A project's settings pages, which the project's menu drills into. */
497const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/;
498
499/**
500 * The sidebar's lists are a stack, the way a phone pushes a screen: one
501 * drilled into slides in from the right over the full width, while the one
502 * it covers drifts a quarter of the way left and fades, both on one long
503 * ease-out, the fade quicker than the move so the two never blur together.
504 * Going back reverses it.
505 */
506const LAYER =
507 "absolute inset-0 [transition:translate_380ms_cubic-bezier(0.32,0.72,0,1),opacity_220ms_ease-out] will-change-[translate,opacity] motion-reduce:transition-none";
508/** One list, filling its layer. */
509const PANEL = "h-full overflow-y-auto px-2 pb-4";
510
511/** One list in the stack: what it is, and its rows. */
512type Level = { key: string; node: ReactNode };
513
514/** True once the page has hydrated, so what the server drew never animates in. */
515function useHydrated(): boolean {
516 const [hydrated, setHydrated] = useState(false);
517 useEffect(() => setHydrated(true), []);
518 return hydrated;
519}
520
521/**
522 * Drilling in and out of the sidebar's lists. `trail` is the way from the
523 * main list to the one shown, worked out from the address, so a link
524 * straight to a settings page opens the sidebar already drilled in, and
525 * leaving it comes back out. Lists deeper than the one shown stay in
526 * place, off to the right, so going back slides them away rather than
527 * dropping them. Moving to a list at the same depth (a workspace's settings
528 * to a project) fades the new one in where it is.
529 */
530function Drill({ trail }: { trail: Level[] }) {
531 const depth = trail.length - 1;
532 const hydrated = useHydrated();
533 const stack = useRef<Level[]>(trail);
534 const before = stack.current;
535 const within = trail.every((level, index) => before[index]?.key === level.key);
536 stack.current = within ? [...trail, ...before.slice(trail.length)] : trail;
537 const levels = stack.current;
538 // What was showing last time, to tell drilling in from moving across.
539 const shown = useRef({ depth, keys: levels.map((level) => level.key) });
540 useEffect(() => {
541 shown.current = { depth, keys: levels.map((level) => level.key) };
542 });
543 return (
544 <div className="relative min-h-0 grow overflow-hidden">
545 {levels.map((level, index) => {
546 const current = index === depth;
547 const place =
548 index < depth
549 ? "pointer-events-none -translate-x-1/4 opacity-0"
550 : current
551 ? "translate-x-0 opacity-100"
552 : "pointer-events-none translate-x-full opacity-0";
553 // A list that was not there before arrives: from the right when
554 // drilling in, in place when moving across.
555 const arriving = hydrated && current && !shown.current.keys.includes(level.key);
556 const enter = arriving
557 ? depth > shown.current.depth
558 ? "starting:translate-x-full starting:opacity-0"
559 : "starting:opacity-0"
560 : "";
561 return (
562 <div key={level.key} className={`${LAYER} ${place} ${enter}`} inert={!current} aria-hidden={!current || undefined}>
563 {level.node}
564 </div>
565 );
566 })}
567 </div>
568 );
569}
570
571/**
572 * The row at the top of a list drilled into: the list's name, which goes
573 * back out, and what it belongs to, faint at the end.
574 */
575function BackRow({ to, label, context }: { to: string; label: ReactNode; context?: ReactNode }) {
576 return (
577 <Link
578 to={to}
579 prefetch="intent"
580 className="group mt-3 flex h-8 items-center gap-1.5 rounded-md pr-2 pl-1 text-[0.8125rem] font-medium text-fg transition-colors hover:bg-raised/60"
581 >
582 <ChevronLeft size={16} className="shrink-0 text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
583 <span className="min-w-0 shrink-0 truncate">{label}</span>
584 {context && (
585 <span className="ml-auto min-w-0 truncate pl-2 font-mono text-[0.6875rem] font-normal text-faint">{context}</span>
586 )}
587 </Link>
588 );
589}
590
591/** A thin rule between groups of a list. */
592function Rule() {
593 return <div role="separator" className="mx-2 my-2.5 h-px bg-line" />;
594}
595
596/**
597 * The main list: where you go, the projects, what the workspace builds and
598 * runs with across them, then its usage, support and settings.
599 */
600function MainMenu({ user, shell }: { user: User | null; shell: ShellData }) {
601 const ws = shell.workspace;
602 const active = shell.repo;
603 // The repository being looked at is listed even when it is someone else's.
604 const listed =
605 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
606 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
607 : shell.repos;
608 // A visitor browses: no workspace, no projects of their own.
609 if (!user) {
610 return (
611 <nav aria-label="g1t" className={PANEL}>
612 <div className="mt-3 space-y-px">
613 {VISITOR_LINKS.map((link) => (
614 <SidebarLink key={link.to} to={link.to} icon={link.to === "/search" ? <Search size={15} /> : <Compass size={15} />}>
615 {link.label}
616 </SidebarLink>
617 ))}
618 </div>
619 </nav>
620 );
621 }
622 return (
623 <nav aria-label="g1t" className={PANEL}>
624 <div className="mt-3 space-y-px">
625 <SidebarLink to="/" end icon={<House size={15} />}>
626 Mission control
627 </SidebarLink>
628 {ws && (
629 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutGrid size={15} />}>
630 Overview
631 </SidebarLink>
632 )}
633 <SidebarLink to="/explore" icon={<Compass size={15} />}>
634 Explore
635 </SidebarLink>
636 </div>
637
638 <Rule />
639 <SidebarGroup
640 title="Projects"
641 action={
642 ws && (
643 <Link
644 to={`/new?workspace=${ws.slug}`}
645 aria-label="New project"
646 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
647 >
648 <Plus size={13} />
649 </Link>
650 )
651 }
652 >
653 {listed.length === 0 && <p className="px-2 py-1 text-xs text-faint">None yet.</p>}
654 {listed.map((repo) => (
655 <SidebarLink
656 key={`${repo.namespace}/${repo.name}`}
657 to={`/${repo.namespace}/${repo.name}`}
658 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
659 drill="hover"
660 >
661 {repo.namespace !== ws?.slug && <span className="font-mono text-faint">{repo.namespace}/</span>}
662 {"title" in repo && repo.title ? repo.title : repo.name}
663 </SidebarLink>
664 ))}
665 </SidebarGroup>
666 {(shell.shared ?? []).length > 0 && (
667 <SidebarGroup title="Shared with you" className="mt-3">
668 {(shell.shared ?? []).map((repo) => (
669 <SidebarLink
670 key={`${repo.namespace}/${repo.name}`}
671 to={`/${repo.namespace}/${repo.name}`}
672 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
673 drill="hover"
674 >
675 <span className="font-mono text-faint">{repo.namespace}/</span>
676 {repo.name}
677 </SidebarLink>
678 ))}
679 </SidebarGroup>
680 )}
681
682 {ws && (
683 <>
684 <Rule />
685 <div className="space-y-px">
686 <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}>
687 Agent fleet
688 </SidebarLink>
689 <SidebarLink to={`/${ws.slug}/-/context`} icon={<Network size={15} />}>
690 Context
691 </SidebarLink>
692 <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />}>
693 Memory
694 </SidebarLink>
695 <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />}>
696 Security
697 </SidebarLink>
698 <SidebarLink to={`/${ws.slug}/-/guardrails`} icon={<Gauge size={15} />}>
699 Guardrails
700 </SidebarLink>
701 <SidebarLink to={`/${ws.slug}/-/secrets`} icon={<Lock size={15} />}>
702 Secrets and variables
703 </SidebarLink>
704 <SidebarLink to={`/${ws.slug}/-/runners`} icon={<ServerCog size={15} />}>
705 Runners
706 </SidebarLink>
707 <SidebarLink to={`/${ws.slug}/-/integrations`} icon={<Plug size={15} />}>
708 Integrations
709 </SidebarLink>
710 <SidebarLink to={`/${ws.slug}/-/webhooks`} icon={<Webhook size={15} />}>
711 Webhooks
712 </SidebarLink>
713 {roadmapIn("Workspace").map((item) => (
714 <SidebarSoonLink
715 key={item.key}
716 to={`/${ws.slug}/-/soon/${item.key}`}
717 icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />}
718 about={item.summary}
719 >
720 {item.title === "Board" ? "Boards" : item.title}
721 </SidebarSoonLink>
722 ))}
723 </div>
724
725 <Rule />
726 <div className="space-y-px">
727 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
728 Usage
729 </SidebarLink>
730 <SidebarLink to="/support" icon={<LifeBuoy size={15} />}>
731 Support
732 </SidebarLink>
733 {/* Who belongs, what it pays and its record: a list of their own. */}
734 <SidebarLink
735 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
736 icon={<Settings size={15} />}
737 drill
738 >
739 Settings
740 </SidebarLink>
741 </div>
742 </>
743 )}
744 </nav>
745 );
746}
747
748/** A workspace's settings, drilled into from Settings in the main list. */
749function SettingsMenu({ slug, owner }: { slug: string; owner: boolean }) {
750 return (
751 <nav aria-label="Workspace settings" className={PANEL}>
752 <BackRow to={`/${slug}`} label="Settings" context={slug} />
753 <div className="mt-2 space-y-px">
754 {owner && (
755 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
756 General
757 </SidebarLink>
758 )}
759 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
760 Members
761 </SidebarLink>
762 <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}>
763 Repositories
764 </SidebarLink>
765 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
766 Access tokens
767 </SidebarLink>
768 </div>
769 <Rule />
770 <div className="space-y-px">
771 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
772 Billing and plans
773 </SidebarLink>
774 <SidebarLink to={`/${slug}/-/audit`} icon={<History size={15} />}>
775 Audit log
776 </SidebarLink>
777 </div>
778 </nav>
779 );
780}
781
782/**
783 * What the repository menu needs. Known from the address before the page's
784 * data arrives, so the menu is right from the first frame; the counts fill
785 * in once it does.
786 */
787type MenuRepo = {
788 namespace: string;
789 name: string;
790 member: boolean;
791 /** Whether they see its settings; from the repository's page once it loads. */
792 settings?: boolean;
793 /** What they may do there, once the repository's page loads. */
794 can?: Abilities;
795 issues?: number;
796 pulls?: number;
797};
798
799function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
800 return (
801 a != null &&
802 b != null &&
803 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
804 a.name.toLowerCase() === b.name.toLowerCase()
805 );
806}
807
808/**
809 * A project's own list, drilled into while you are in it: everything about
810 * the project, running and its code, and nothing else, with the way back
811 * out to all of them.
812 */
813function RepoMenu({
814 repo,
815 isPrivate,
816 back,
817}: {
818 repo: MenuRepo;
819 isPrivate: boolean;
820 /** Where the way back leads: the workspace's projects, mission control, or Explore. */
821 back: { to: string; label: string };
822}) {
823 const base = `/${repo.namespace}/${repo.name}`;
824 // What a member sees, and what everyone who can see the project does.
825 const shows = new Set(projectPages(repo.member, repo.can));
826 return (
827 <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}>
828 <BackRow to={back.to} label={back.label} />
829 <NavLink
830 to={base}
831 end
832 prefetch="intent"
833 title="Overview"
834 className={({ isActive }) =>
835 `mt-2 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
836 }
837 >
838 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
839 {isPrivate ? <Lock size={13} /> : <Box size={13} />}
840 </span>
841 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
842 <span className="text-faint">{repo.namespace}/</span>
843 <span className="font-semibold text-fg">{repo.name}</span>
844 </span>
845 </NavLink>
846 {/* A project's pages, in the order people use them. A page with more
847 than one view shows them as tabs across its top. */}
848 <Rule />
849 <div className="space-y-px">
850 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
851 Code
852 </SidebarLink>
853 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
854 Issues
855 </SidebarLink>
856 <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
857 Pull requests
858 </SidebarLink>
859 <SidebarLink to={`${base}/agents`} also={[`${base}/sessions`, `${base}/memory`, ...soonPaths(base, "Agents")]} icon={<Bot size={15} />}>
860 Agents
861 </SidebarLink>
862 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
863 Workflows
864 </SidebarLink>
865 </div>
866 <Rule />
867 <div className="space-y-px">
868 {shows.has("deployments") ? (
869 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
870 Deployments
871 </SidebarLink>
872 ) : null}
873 <SidebarSoonLink to={`${base}/soon/logs`} also={soonPaths(base, "Observability")} icon={<Activity size={15} />} about="Logs, errors, uptime and analytics of the project's deployed apps.">
874 Observability
875 </SidebarSoonLink>
876 {shows.has("security") ? (
877 <SidebarLink to={`${base}/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />}>
878 Security
879 </SidebarLink>
880 ) : null}
881 <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do.">
882 Insights
883 </SidebarSoonLink>
884 </div>
885 {(repo.settings ?? shows.has("settings")) && (
886 <>
887 <Rule />
888 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />} drill>
889 Settings
890 </SidebarLink>
891 </>
892 )}
893 </nav>
894 );
895}
896
897/**
898 * A project's settings, drilled into from its list: what is about running
899 * it, then its agents, then its code and who can reach it. The way back
900 * leads to the project.
901 */
902function RepoSettingsMenu({ repo }: { repo: MenuRepo }) {
903 const base = `/${repo.namespace}/${repo.name}`;
904 // Each page shows to the roles that can use it, once the role is known.
905 const shows = (page: string) => !repo.can || repo.can[SETTINGS_CAPABILITY[page] ?? "administer"];
906 const running = shows("deployments");
907 const agents = shows("agents") || shows("guardrails");
908 return (
909 <nav aria-label={`${repo.namespace}/${repo.name} settings`} className={PANEL}>
910 <BackRow to={base} label="Settings" context={`${repo.namespace}/${repo.name}`} />
911 {shows("") && (
912 <div className="mt-2 space-y-px">
913 <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />}>
914 General
915 </SidebarLink>
916 </div>
917 )}
918 {running && (
919 <>
920 {shows("") && <Rule />}
921 <div className={shows("") ? "space-y-px" : "mt-2 space-y-px"}>
922 <SidebarLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}>
923 Deployments
924 </SidebarLink>
925 <SidebarLink to={`${base}/settings/domains`} icon={<Globe size={15} />}>
926 Domains
927 </SidebarLink>
928 <SidebarLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}>
929 Dependencies
930 </SidebarLink>
931 </div>
932 </>
933 )}
934 {agents && (
935 <>
936 <Rule />
937 <div className="space-y-px">
938 {shows("agents") && (
939 <SidebarLink to={`${base}/settings/agents`} icon={<Bot size={15} />}>
940 Agents
941 </SidebarLink>
942 )}
943 {shows("guardrails") && (
944 <SidebarLink to={`${base}/settings/guardrails`} icon={<ShieldCheck size={15} />}>
945 Guardrails
946 </SidebarLink>
947 )}
948 </div>
949 </>
950 )}
951 <Rule />
952 <div className="space-y-px">
953 {shows("repository") && (
954 <SidebarLink to={`${base}/settings/repository`} icon={<BookMarked size={15} />}>
955 Repository
956 </SidebarLink>
957 )}
958 {shows("access") && (
959 <SidebarLink to={`${base}/settings/access`} icon={<Users size={15} />}>
960 Access
961 </SidebarLink>
962 )}
963 {shows("branches") && (
964 <SidebarLink to={`${base}/settings/branches`} icon={<GitBranch size={15} />}>
965 Branches and merging
966 </SidebarLink>
967 )}
968 {shows("secrets") && (
969 <SidebarLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}>
970 Secrets and variables
971 </SidebarLink>
972 )}
973 {shows("runners") && (
974 <SidebarLink to={`${base}/settings/runners`} icon={<ServerCog size={15} />}>
975 Runners
976 </SidebarLink>
977 )}
978 {shows("webhooks") && (
979 <SidebarLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
980 Webhooks
981 </SidebarLink>
982 )}
983 </div>
984 </nav>
985 );
986}
987
988/** Your own settings, drilled into from Your settings: one page each. */
989function AccountSettingsMenu({ username }: { username: string }) {
990 const link = (page: AccountSettingsPage, icon: ReactNode) => (
991 <SidebarLink to={`/settings/${page}`} icon={icon}>
992 {ACCOUNT_SETTINGS[page].title}
993 </SidebarLink>
994 );
995 return (
996 <nav aria-label="Your settings" className={PANEL}>
997 <BackRow to="/" label="Your settings" context={username} />
998 <div className="mt-2 space-y-px">
999 {link("profile", <CircleUserRound size={15} />)}
1000 {link("emails", <Mail size={15} />)}
1001 {link("invites", <Ticket size={15} />)}
1002 </div>
1003 <Rule />
1004 <div className="space-y-px">
1005 {link("keys", <Fingerprint size={15} />)}
1006 {link("tokens", <KeyRound size={15} />)}
1007 </div>
1008 <Rule />
1009 <div className="space-y-px">
1010 {link("github", <GithubMark className="size-[15px]" />)}
1011 {link("applications", <Plug size={15} />)}
1012 </div>
1013 <Rule />
1014 <div className="space-y-px">
1015 {link("security-log", <History size={15} />)}
1016 </div>
1017 </nav>
1018 );
1019}
1020
1021/**
1022 * Signing in and signing up, in place of the account for a visitor. Signing
1023 * in brings them back to the page they are on.
1024 */
1025/** Help, status and the fine print under a visitor's sign-in buttons. */
1026function VisitorLinks() {
1027 const status = useSiteStatus();
1028 const quiet = `${LEGAL_LINK} hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-merged/60`;
1029 const dot = (
1030 <span aria-hidden="true" className="text-[0.625rem] text-line-strong">
1031 ·
1032 </span>
1033 );
1034 return (
1035 // Two short rows, so a narrow sidebar never starts a line with a dot.
1036 <nav aria-label="About g1t" className="pt-0.5">
1037 <div className="flex items-center gap-0.5">
1038 <a href={STATUS_URL} className={`${quiet} gap-1.5`}>
1039 <StatusDot state={status?.overall.state ?? null} />
1040 Status
1041 </a>
1042 {dot}
1043 <Link to="/support" className={quiet}>
1044 Support
1045 </Link>
1046 </div>
1047 <div className="flex items-center gap-0.5">
1048 {LEGAL_LINKS.map(([label, to], index) => (
1049 <span key={to} className="flex items-center gap-0.5">
1050 {index > 0 && dot}
1051 <Link to={to} className={quiet}>
1052 {label}
1053 </Link>
1054 </span>
1055 ))}
1056 </div>
1057 </nav>
1058 );
1059}
1060
1061function VisitorPanel() {
1062 const { pathname, search } = useLocation();
1063 const signUp = useSignUpCopy();
1064 return (
1065 <div className="space-y-2">
1066 <p className="px-1 text-xs text-muted">Sign in to open issues, review pull requests and run agents.</p>
1067 <div className="grid grid-cols-2 gap-2">
1068 <Link
1069 to={withNext("/login", pathname + search)}
1070 className="flex h-9 items-center justify-center gap-1.5 rounded-md border border-line text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"
1071 >
1072 <LogIn size={14} />
1073 Sign in
1074 </Link>
1075 <Link
1076 to={withNext("/register", pathname + search)}
1077 className="flex h-9 items-center justify-center rounded-md bg-fg text-[0.8125rem] font-medium text-bg transition-colors hover:bg-white"
1078 >
1079 {signUp.primary}
1080 </Link>
1081 </div>
1082 <VisitorLinks />
1083 </div>
1084 );
1085}
1086
1087function Sidebar({
1088 user,
1089 shell,
1090 missing = false,
1091 onFind,
1092 onClose,
1093}: {
1094 user: User | null;
1095 shell: ShellData;
1096 /** The page is a 404: the address names nothing the viewer can see. */
1097 missing?: boolean;
1098 onFind: () => void;
1099 /** In the sheet on a small screen: closing it, at the end of the top row. */
1100 onClose?: () => void;
1101}) {
1102 const ws = shell.workspace;
1103 const { pathname } = useLocation();
1104 const going = useNavigation().location?.pathname;
1105 // Where the sidebar is drilled to follows the page being gone to, so it
1106 // moves as the link is followed, not once the page arrives.
1107 const target = going ?? pathname;
1108 // On a 404, the address is not a place: no list is drilled into from it,
1109 // except the project's own when the project is real and visible and only
1110 // something inside it is missing (a file, a commit, an issue).
1111 const lost = missing && going == null;
1112 const inSettings = !lost && ws != null && SETTINGS_PAGE.exec(target)?.[1]?.toLowerCase() === ws.slug;
1113 const inAccount = !lost && target === "/settings" || target.startsWith("/settings/");
1114 const active = shell.repo;
1115 // In a repository, or on the way into one, its own list.
1116 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
1117 const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth", "policies", "security", "support", "status", "invite", ".well-known"]);
1118 // An invitation to a repository is answered before its menu means anything.
1119 const inRepo =
1120 repoPath != null &&
1121 !reserved.has(repoPath[1]) &&
1122 repoPath[2] !== "-" &&
1123 !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target) &&
1124 (!lost || sameRepo(active, { namespace: repoPath[1]!, name: repoPath[2]! }));
1125 // A project's settings, one level further in.
1126 const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target);
1127 // The repository the menu is for: the one loaded if it is the one being
1128 // gone to, else what the address says, at once.
1129 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
1130 const guessed: MenuRepo | null = targetRepo
1131 ? sameRepo(active, targetRepo)
1132 ? active
1133 : {
1134 ...targetRepo,
1135 member: (user?.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
1136 }
1137 : null;
1138 // The viewer's role there, from the repository's page once it has loaded.
1139 const page = useRouteLoaderData("routes/repo/layout") as
1140 | { repo?: { namespace: string; name: string }; access?: ViewerAccess }
1141 | undefined;
1142 const menuRepo: MenuRepo | null =
1143 guessed && page?.access && page.repo && sameRepo(page.repo, guessed)
1144 ? { ...guessed, member: page.access.insider, settings: seesSettings(page.access), can: page.access.can }
1145 : guessed;
1146
1147 // The way from the main list to the one shown.
1148 const trail: Level[] = [{ key: "main", node: <MainMenu user={user} shell={shell} /> }];
1149 if (user && inAccount) {
1150 trail.push({ key: "account", node: <AccountSettingsMenu username={user.username} /> });
1151 } else if (ws && inSettings) {
1152 trail.push({ key: `settings:${ws.slug}`, node: <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} /> });
1153 } else if (menuRepo) {
1154 const key = `repo:${menuRepo.namespace}/${menuRepo.name}`.toLowerCase();
1155 // Out of a project: to its workspace's projects when they are yours.
1156 const home = (user?.workspaces ?? []).find((m) => m.slug === menuRepo.namespace.toLowerCase());
1157 const back = !user
1158 ? { to: "/explore", label: "Explore" }
1159 : home
1160 ? { to: `/${home.slug}`, label: "All projects" }
1161 : { to: "/", label: "Mission control" };
1162 trail.push({
1163 key,
1164 node: (
1165 <RepoMenu
1166 repo={menuRepo}
1167 isPrivate={[...shell.repos, ...(shell.shared ?? [])].some((repo) => sameRepo(repo, menuRepo) && repo.isPrivate)}
1168 back={back}
1169 />
1170 ),
1171 });
1172 if (inRepoSettings) trail.push({ key: `${key}:settings`, node: <RepoSettingsMenu repo={menuRepo} /> });
1173 }
1174
1175 return (
1176 <div className="flex h-full flex-col">
1177 {/* The same height and rule as the top bar, so the two read as one line. */}
1178 <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
1179 {user ? (
1180 <>
1181 <Link to="/" aria-label="g1t home" className="flex h-9 shrink-0 items-center rounded-md px-2 transition-colors hover:bg-raised">
1182 <Logo className="text-[1.25rem]" />
1183 </Link>
1184 <span className="shrink-0 text-line-strong" aria-hidden="true">
1185 /
1186 </span>
1187 <WorkspaceSwitcher user={user} shell={shell} />
1188 </>
1189 ) : (
1190 <Link to="/" aria-label="g1t home" className="mr-auto flex h-9 items-center rounded-md px-2 transition-colors hover:bg-raised">
1191 <Logo className="text-[1.25rem]" />
1192 </Link>
1193 )}
1194 {onClose && (
1195 <button
1196 type="button"
1197 aria-label="Close menu"
1198 onClick={onClose}
1199 className="flex size-9 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"
1200 >
1201 <X size={16} />
1202 </button>
1203 )}
1204 </div>
1205 <div className="px-2 pt-3">
1206 <button
1207 type="button"
1208 onClick={onFind}
1209 className="flex h-9 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"
1210 >
1211 <Search size={14} />
1212 <span className="grow text-left">Find…</span>
1213 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
1214 </button>
1215 </div>
1216 <Drill trail={trail} />
1217 {/* The account, or signing in, as one row at the very bottom. */}
1218 <div className="shrink-0 border-t border-line p-2">
1219 {user ? <AccountMenu user={user} /> : <VisitorPanel />}
1220 </div>
1221 </div>
1222 );
1223}
1224
1225/** Words for the sections a path can end in. */
1226const SECTIONS: Record<string, string> = {
1227 issues: "Issues",
1228 pulls: "Pull requests",
1229 queue: "Merge queue",
1230 commits: "Commits",
1231 plans: "Outcomes",
1232 actions: "Workflows",
1233 soon: "Soon",
1234 deployments: "Deployments",
1235 repository: "Repository",
1236 access: "Access",
1237 invitations: "Invitation",
1238 repositories: "Repositories",
1239 branches: "Branches and merging",
1240 dependencies: "Dependencies",
1241 code: "Files",
1242 secrets: "Secrets and variables",
1243 settings: "Settings",
1244 people: "Members",
1245 tokens: "Access tokens",
1246 usage: "Usage",
1247 billing: "Billing and plans",
1248 integrations: "Integrations",
1249 webhooks: "Webhooks",
1250 domains: "Domains",
1251 guardrails: "Guardrails",
1252 audit: "Audit log",
1253 tree: "Code",
1254 blob: "Code",
1255};
1256
1257/** Where the page is, as a trail of links: workspace / repository / section. */
1258function Breadcrumbs({ pathname, missing, repo }: { pathname: string; missing?: boolean; repo?: ShellData["repo"] }) {
1259 // A 404 names nothing from the address, unless the project is real and
1260 // visible and only something inside it is missing.
1261 const visibleRepo = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(pathname);
1262 if (missing && !(visibleRepo && sameRepo(repo ?? null, { namespace: visibleRepo[1]!, name: visibleRepo[2]! }))) {
1263 return <span className="text-sm font-medium">Not found</span>;
1264 }
1265 const parts = pathname.split("/").filter(Boolean);
1266 const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite"];
1267 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
1268 // Your settings: Settings / Emails.
1269 if (parts[0] === "settings") {
1270 const page = accountSettingsPage(pathname);
1271 const trail = [{ label: "Settings", to: FIRST_SETTINGS_PAGE }, ...(page ? [{ label: ACCOUNT_SETTINGS[page].title, to: `/settings/${page}` }] : [])];
1272 return (
1273 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
1274 {trail.map((crumb, index) => (
1275 <span key={crumb.to + index} className="flex min-w-0 items-center gap-1.5">
1276 {index > 0 && <span className="text-line-strong">/</span>}
1277 <Link
1278 to={crumb.to}
1279 prefetch="intent"
1280 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
1281 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
1282 }`}
1283 >
1284 {crumb.label}
1285 </Link>
1286 </span>
1287 ))}
1288 </nav>
1289 );
1290 }
1291 if (reserved.includes(parts[0]!)) {
1292 const words: Record<string, string> = {
1293 settings: "Account",
1294 explore: "Explore",
1295 new: "New project",
1296 search: "Search",
1297 workspaces: "New workspace",
1298 policies: "Policies",
1299 security: "Security",
1300 support: "Support",
1301 status: "Status",
1302 invite: "Invite",
1303 };
1304 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
1305 }
1306 // A person's profile, by their handle.
1307 if (parts[0] === "u" && parts[1]) {
1308 return <span className="truncate font-mono text-[0.8125rem] font-medium">@{parts[1]}</span>;
1309 }
1310 const [owner, second, third, fourth] = parts;
1311 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
1312 if (second === "-") {
1313 const page = `/${owner}/-/${third}`;
1314 if (third && SETTINGS_PAGE.test(page)) {
1315 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
1316 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
1317 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
1318 } else if (second) {
1319 const repo = `/${owner}/${second}`;
1320 trail.push({ label: second, to: repo, mono: true });
1321 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
1322 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
1323 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
1324 else if (third && SECTIONS[third]) {
1325 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
1326 // A settings page names which one: Settings / Secrets and variables.
1327 if (third === "settings" && fourth && SECTIONS[fourth]) {
1328 trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
1329 }
1330 }
1331 }
1332 return (
1333 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
1334 {trail.map((crumb, index) => (
1335 <span key={index} className="flex min-w-0 items-center gap-1.5">
1336 {index > 0 && <span className="text-line-strong">/</span>}
1337 <Link
1338 to={crumb.to}
1339 prefetch="intent"
1340 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
1341 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
1342 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
1343 >
1344 {crumb.label}
1345 </Link>
1346 </span>
1347 ))}
1348 </nav>
1349 );
1350}
1351
1352type Command = PaletteCommand;
1353
1354/** Everything the palette can jump to, from what the sidebar already knows. */
1355function commandsFor(user: User | null, shell: ShellData, here: string, signUpLabel = "Sign up"): Command[] {
1356 if (!user) return visitorCommands(shell, here, signUpLabel);
1357 const commands: Command[] = [
1358 { label: "Mission control", to: "/", icon: <House size={15} /> },
1359 ...(shell.repos.length > 0
1360 ? [{ label: "Put an agent on it", hint: "Open an issue and assign g1t-agent", to: "/?agent=new", icon: <Sparkles size={15} /> }]
1361 : []),
1362 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
1363 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1364 { label: "New project", to: "/new", icon: <Plus size={15} /> },
1365 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
1366 { label: "Your settings", to: FIRST_SETTINGS_PAGE, icon: <Settings size={15} /> },
1367 ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[])
1368 .filter((page) => page !== "profile")
1369 .map((page) => ({ label: ACCOUNT_SETTINGS[page].title, hint: "Your settings", to: `/settings/${page}`, icon: <Settings size={15} /> })),
1370 ];
1371 const repo = shell.repo;
1372 if (repo) {
1373 const base = `/${repo.namespace}/${repo.name}`;
1374 const name = `${repo.namespace}/${repo.name}`;
1375 commands.unshift(
1376 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
1377 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
1378 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
1379 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
1380 ...(repo.member
1381 ? [{ label: `Put an agent on ${repo.name}`, hint: name, to: `${base}/issues/new?agent=1`, icon: <Sparkles size={15} /> }]
1382 : []),
1383 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
1384 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
1385 ...(repo.member
1386 ? [
1387 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
1388 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
1389 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
1390 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
1391 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
1392 ]
1393 : []),
1394 );
1395 }
1396 for (const membership of user.workspaces ?? []) {
1397 commands.push(
1398 { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
1399 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
1400 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
1401 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
1402 { label: "Integrations", hint: membership.slug, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
1403 );
1404 }
1405 for (const listed of shell.repos) {
1406 commands.push({
1407 label: listed.title ?? listed.name,
1408 hint: `${listed.namespace} · Project`,
1409 to: `/${listed.namespace}/${listed.name}`,
1410 icon: <Box size={15} />,
1411 });
1412 }
1413 // "Put an agent on …": one per project, after the projects themselves.
1414 for (const listed of shell.repos) {
1415 if (repo && listed.namespace === repo.namespace && listed.name === repo.name) continue;
1416 commands.push({
1417 label: `Put an agent on ${listed.title ?? listed.name}`,
1418 hint: `${listed.namespace}/${listed.name}`,
1419 to: `/${listed.namespace}/${listed.name}/issues/new?agent=1`,
1420 icon: <Sparkles size={15} />,
1421 });
1422 }
1423 return commands;
1424}
1425
1426/** What the palette offers a visitor: browsing, the project they are in, and signing in. */
1427function visitorCommands(shell: ShellData, here: string, signUpLabel: string): Command[] {
1428 const commands: Command[] = [];
1429 const repo = shell.repo;
1430 if (repo) {
1431 const base = `/${repo.namespace}/${repo.name}`;
1432 const name = `${repo.namespace}/${repo.name}`;
1433 commands.push(
1434 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
1435 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
1436 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
1437 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
1438 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
1439 );
1440 }
1441 commands.push(
1442 { label: "Explore", hint: "Public projects", to: "/explore", icon: <Compass size={15} /> },
1443 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1444 { label: "Pricing", to: "/pricing", icon: <CreditCard size={15} /> },
1445 { label: "Documentation", to: "https://docs.g1t.sh/", icon: <BookOpen size={15} /> },
1446 { label: "Sign in", to: withNext("/login", here), icon: <LogIn size={15} /> },
1447 { label: signUpLabel, to: withNext("/register", here), icon: <Plus size={15} /> },
1448 );
1449 return commands;
1450}
1451
1452/**
1453 * A bar across the top of the page while the next one loads, as GitHub
1454 * has: it appears at once, creeps towards the end while waiting, then fills
1455 * and fades when the page arrives.
1456 */
1457export function Progress() {
1458 const navigation = useNavigation();
1459 const busy = navigation.state !== "idle";
1460 const [width, setWidth] = useState(0);
1461 const [visible, setVisible] = useState(false);
1462 useEffect(() => {
1463 if (busy) {
1464 setVisible(true);
1465 setWidth(12);
1466 // Slows as it goes, never quite arriving until the page does.
1467 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
1468 return () => clearInterval(timer);
1469 }
1470 setWidth((now) => (now > 0 ? 100 : 0));
1471 const fade = setTimeout(() => setVisible(false), 250);
1472 const reset = setTimeout(() => setWidth(0), 550);
1473 return () => {
1474 clearTimeout(fade);
1475 clearTimeout(reset);
1476 };
1477 }, [busy]);
1478 return (
1479 <div
1480 aria-hidden="true"
1481 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
1482 visible ? "opacity-100" : "opacity-0"
1483 }`}
1484 >
1485 <div
1486 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
1487 style={{ width: `${width}%` }}
1488 />
1489 </div>
1490 );
1491}
1492
1493/**
1494 * The app: a sidebar with the workspace, its repositories and the sections
1495 * of the one being looked at; a slim bar with search and the account; and
1496 * the page. A visitor who is not signed in gets the same frame, with
1497 * Explore and Search in place of the workspace, and signing in in place of
1498 * the account.
1499 */
1500export function AppShell({
1501 user,
1502 shell,
1503 missing = false,
1504 banner,
1505 children,
1506}: {
1507 user: User | null;
1508 shell: ShellData;
1509 /** The page is a 404 (see Sidebar). */
1510 missing?: boolean;
1511 banner?: ReactNode;
1512 children: ReactNode;
1513}) {
1514 const { pathname, search } = useLocation();
1515 const [drawer, setDrawer] = useState(false);
1516 const [palette, setPalette] = useState(false);
1517 const here = pathname + search;
1518 const signUpLabel = useSignUpCopy().primary;
1519 const commands = useMemo(() => commandsFor(user, shell, here, signUpLabel), [user, shell, here, signUpLabel]);
1520
1521 // A new page closes the drawer on small screens.
1522 useEffect(() => setDrawer(false), [pathname]);
1523 // Mission control's code, fetched while the browser is idle, so going
1524 // home never waits on it (routes/home.tsx).
1525 useEffect(() => {
1526 if (!user) return;
1527 const load = () => void import("./mission-control").catch(() => undefined);
1528 const idle = (window as { requestIdleCallback?: (callback: () => void) => number }).requestIdleCallback;
1529 if (idle) idle(load);
1530 else setTimeout(load, 1500);
1531 }, [user]);
1532 usePaletteShortcut(() => setPalette((open) => !open));
1533
1534 return (
1535 <div className="min-h-screen">
1536 <Progress />
1537 <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">
1538 <Sidebar user={user} shell={shell} missing={missing} onFind={() => setPalette(true)} />
1539 </aside>
1540 {drawer && (
1541 <div className="fixed inset-0 z-50 lg:hidden">
1542 <button
1543 type="button"
1544 aria-label="Close menu"
1545 className="absolute inset-0 bg-black/60"
1546 onClick={() => setDrawer(false)}
1547 />
1548 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
1549 <Sidebar user={user} shell={shell} missing={missing} onFind={() => setPalette(true)} onClose={() => setDrawer(false)} />
1550 </aside>
1551 </div>
1552 )}
1553
1554 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
1555 <header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6">
1556 <button
1557 type="button"
1558 aria-label="Open menu"
1559 onClick={() => setDrawer(true)}
1560 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
1561 >
1562 <Menu size={18} />
1563 </button>
1564 <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} />
1565 <Form action="/search" role="search" className="relative ml-auto hidden w-full max-w-64 md:block">
1566 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
1567 <input
1568 name="q"
1569 {...notACredential()}
1570 placeholder="Search g1t"
1571 aria-label="Search g1t"
1572 className="h-9 w-full rounded-md border border-line bg-surface pr-12 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
1573 />
1574 <kbd className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">
1575 ⌘K
1576 </kbd>
1577 </Form>
1578 <div className="ml-auto flex items-center gap-1.5 md:ml-0">
1579 <a
1580 href="https://docs.g1t.sh/"
1581 className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
1582 >
1583 Docs
1584 </a>
1585 {!user ? (
1586 // The sidebar has these too, but on a phone it is folded away.
1587 <Link
1588 to={withNext("/login", here)}
1589 className="flex h-9 items-center rounded-md bg-fg px-3 text-sm font-medium text-bg transition-colors hover:bg-white lg:hidden"
1590 >
1591 Sign in
1592 </Link>
1593 ) : (
1594 <DropdownMenu>
1595 <DropdownMenuTrigger
1596 aria-label="Create"
1597 className="flex h-9 items-center gap-1.5 rounded-md bg-fg px-3 text-sm font-medium text-bg outline-none transition-colors hover:bg-white"
1598 >
1599 <Plus size={14} />
1600 New
1601 </DropdownMenuTrigger>
1602 <DropdownMenuContent align="end">
1603 {shell.repo && (
1604 <DropdownMenuItem asChild>
1605 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
1606 <CircleDot />
1607 New issue
1608 </Link>
1609 </DropdownMenuItem>
1610 )}
1611 {shell.repo?.member && (
1612 <DropdownMenuItem asChild>
1613 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
1614 <ListTree />
1615 Plan work
1616 </Link>
1617 </DropdownMenuItem>
1618 )}
1619 <DropdownMenuItem asChild>
1620 <Link to="/new">
1621 <Box />
1622 New project
1623 </Link>
1624 </DropdownMenuItem>
1625 <DropdownMenuItem asChild>
1626 <Link to="/workspaces/new">
1627 <Users />
1628 New workspace
1629 </Link>
1630 </DropdownMenuItem>
1631 </DropdownMenuContent>
1632 </DropdownMenu>
1633 )}
1634 </div>
1635 </header>
1636 {banner}
1637 <main className="min-w-0 grow">{children}</main>
1638 </div>
1639 <CommandPalette
1640 open={palette}
1641 onOpenChange={setPalette}
1642 commands={commands}
1643 repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null}
1644 />
1645 </div>
1646 );
1647}